diff --git a/.changeset/tall-bobcats-trade.md b/.changeset/tall-bobcats-trade.md new file mode 100644 index 0000000000..0220b2d1ed --- /dev/null +++ b/.changeset/tall-bobcats-trade.md @@ -0,0 +1,35 @@ +--- +'@xstate/react': patch +'@xstate/svelte': patch +'@xstate/vue': patch +--- + +The `useSelector(…)` hook from `@xstate/react` is now compatible with stores from `@xstate/store`. + +```tsx +import { createStore } from '@xstate/store'; +import { useSelector } from '@xstate/react'; + +const store = createStore( + { + count: 0 + }, + { + inc: { + count: (context) => context.count + 1 + } + } +); + +function Counter() { + // Note that this `useSelector` is from `@xstate/react`, + // not `@xstate/store/react` + const count = useSelector(store, (state) => state.context.count); + + return ( +
+ +
+ ); +} +``` diff --git a/packages/xstate-react/src/useSelector.ts b/packages/xstate-react/src/useSelector.ts index e7ffcb7030..da73946f65 100644 --- a/packages/xstate-react/src/useSelector.ts +++ b/packages/xstate-react/src/useSelector.ts @@ -10,10 +10,15 @@ function defaultCompare(a: T, b: T) { return a === b; } -export function useSelector( +export function useSelector< + TActor extends Pick | undefined, + T +>( actor: TActor, selector: ( - emitted: TActor extends AnyActorRef ? SnapshotFrom : undefined + snapshot: TActor extends { getSnapshot(): infer TSnapshot } + ? TSnapshot + : undefined ) => T, compare: (a: T, b: T) => boolean = defaultCompare ): T { diff --git a/packages/xstate-store/src/store.ts b/packages/xstate-store/src/store.ts index f626d156d7..96d701379f 100644 --- a/packages/xstate-store/src/store.ts +++ b/packages/xstate-store/src/store.ts @@ -208,6 +208,12 @@ declare global { } } +/** + * Creates a store function, which is a function that accepts the current snapshot and an event and returns a new snapshot. + * @param transitions + * @param updater + * @returns + */ export function createStoreTransition< TContext extends StoreContext, TEventPayloadMap extends EventPayloadMap diff --git a/packages/xstate-store/test/UseActor.vue b/packages/xstate-store/test/UseActor.vue new file mode 100644 index 0000000000..5e935de3e6 --- /dev/null +++ b/packages/xstate-store/test/UseActor.vue @@ -0,0 +1,28 @@ + + + diff --git a/packages/xstate-store/test/UseActorRef.vue b/packages/xstate-store/test/UseActorRef.vue new file mode 100644 index 0000000000..304ac41849 --- /dev/null +++ b/packages/xstate-store/test/UseActorRef.vue @@ -0,0 +1,29 @@ + + + diff --git a/packages/xstate-store/test/UseSelector.vue b/packages/xstate-store/test/UseSelector.vue new file mode 100644 index 0000000000..493a8f0469 --- /dev/null +++ b/packages/xstate-store/test/UseSelector.vue @@ -0,0 +1,29 @@ + + + diff --git a/packages/xstate-store/test/react.test.tsx b/packages/xstate-store/test/react.test.tsx index 3dd9c31fe1..b05dcba39f 100644 --- a/packages/xstate-store/test/react.test.tsx +++ b/packages/xstate-store/test/react.test.tsx @@ -1,6 +1,11 @@ import { fireEvent, screen, render } from '@testing-library/react'; -import { createStore } from '../src/index.ts'; +import { createStore, fromStore } from '../src/index.ts'; import { useSelector } from '../src/react.ts'; +import { + useActor, + useActorRef, + useSelector as useXStateSelector +} from '@xstate/react'; import ReactDOM from 'react-dom'; it('useSelector should work', () => { @@ -146,3 +151,118 @@ it('can batch updates', () => { expect(countDiv.textContent).toEqual('2'); }); + +it('useSelector (@xstate/react) should work', () => { + const store = createStore( + { + count: 0 + }, + { + inc: { + count: (ctx) => ctx.count + 1 + } + } + ); + + const Counter = () => { + const count = useXStateSelector(store, (s) => s.context.count); + + return ( +
{ + store.send({ type: 'inc' }); + }} + > + {count} +
+ ); + }; + + render(); + + const countDiv = screen.getByTestId('count'); + + expect(countDiv.textContent).toEqual('0'); + + fireEvent.click(countDiv); + + expect(countDiv.textContent).toEqual('1'); +}); + +it('useActor (@xstate/react) should work', () => { + const store = fromStore( + { + count: 0 + }, + { + inc: { + count: (ctx) => ctx.count + 1 + } + } + ); + + const Counter = () => { + const [snapshot, send] = useActor(store); + + return ( +
{ + send({ type: 'inc' }); + }} + > + {snapshot.context.count} +
+ ); + }; + + render(); + + const countDiv = screen.getByTestId('count'); + + expect(countDiv.textContent).toEqual('0'); + + fireEvent.click(countDiv); + + expect(countDiv.textContent).toEqual('1'); +}); + +it('useActorRef (@xstate/react) should work', () => { + const store = fromStore( + { + count: 0 + }, + { + inc: { + count: (ctx) => ctx.count + 1 + } + } + ); + + const Counter = () => { + const actorRef = useActorRef(store); + const count = useXStateSelector(actorRef, (s) => s.context.count); + + return ( +
{ + actorRef.send({ type: 'inc' }); + }} + > + {count} +
+ ); + }; + + render(); + + const countDiv = screen.getByTestId('count'); + + expect(countDiv.textContent).toEqual('0'); + + fireEvent.click(countDiv); + + expect(countDiv.textContent).toEqual('1'); +}); diff --git a/packages/xstate-store/test/vue.test.ts b/packages/xstate-store/test/vue.test.ts new file mode 100644 index 0000000000..585c441ba1 --- /dev/null +++ b/packages/xstate-store/test/vue.test.ts @@ -0,0 +1,34 @@ +import { fireEvent, render } from '@testing-library/vue'; +import UseSelector from './UseSelector.vue'; +import UseActor from './UseActor.vue'; +import UseActorRef from './UseActorRef.vue'; + +it('works with `useSelector(…)` (@xstate/vue)', async () => { + const { getByTestId } = render(UseSelector); + + const countEl = getByTestId('count'); + const incrementEl = getByTestId('increment'); + + await fireEvent.click(incrementEl); + expect(countEl.textContent).toBe('1'); +}); + +it('works with `useActor(…)` (@xstate/vue)', async () => { + const { getByTestId } = render(UseActor); + + const countEl = getByTestId('count'); + const incrementEl = getByTestId('increment'); + + await fireEvent.click(incrementEl); + expect(countEl.textContent).toBe('1'); +}); + +it('works with `useActorRef(…)` (@xstate/vue)', async () => { + const { getByTestId } = render(UseActorRef); + + const countEl = getByTestId('count'); + const incrementEl = getByTestId('increment'); + + await fireEvent.click(incrementEl); + expect(countEl.textContent).toBe('1'); +}); diff --git a/packages/xstate-svelte/src/useSelector.ts b/packages/xstate-svelte/src/useSelector.ts index 8441fe3566..a0c35b5b30 100644 --- a/packages/xstate-svelte/src/useSelector.ts +++ b/packages/xstate-svelte/src/useSelector.ts @@ -1,13 +1,20 @@ import { readable } from 'svelte/store'; -import type { ActorRef, SnapshotFrom, Subscription } from 'xstate'; +import type { ActorRef, AnyActorRef, SnapshotFrom, Subscription } from 'xstate'; function defaultCompare(a: T, b: T) { return a === b; } -export const useSelector = , T>( +export const useSelector = < + TActor extends Pick, + T +>( actor: TActor, - selector: (snapshot: SnapshotFrom) => T, + selector: ( + snapshot: TActor extends { getSnapshot(): infer TSnapshot } + ? TSnapshot + : undefined + ) => T, compare: (a: T, b: T) => boolean = defaultCompare ) => { let sub: Subscription; diff --git a/packages/xstate-vue/src/useSelector.ts b/packages/xstate-vue/src/useSelector.ts index 8900b9c130..aeb6f53b63 100644 --- a/packages/xstate-vue/src/useSelector.ts +++ b/packages/xstate-vue/src/useSelector.ts @@ -1,5 +1,5 @@ import { Ref, isRef, shallowRef, watch } from 'vue'; -import { ActorRef, SnapshotFrom } from 'xstate'; +import { AnyActorRef } from 'xstate'; function defaultCompare(a: T, b: T) { return a === b; @@ -9,11 +9,14 @@ const noop = () => { /* ... */ }; -export function useSelector | undefined, T>( +export function useSelector< + TActor extends Pick | undefined, + T +>( actor: TActor | Ref, selector: ( - snapshot: TActor extends ActorRef - ? SnapshotFrom + snapshot: TActor extends { getSnapshot(): infer TSnapshot } + ? TSnapshot : undefined ) => T, compare: (a: T, b: T) => boolean = defaultCompare