Skip to content

Commit

Permalink
[@xstate/store] Compatibility with @xstate/react's useSelector(…)
Browse files Browse the repository at this point in the history
… hook (#4844)

* Make `@xstate/store` compatible with `@xstate/react`'s `useSelector(…)` hook

* Add changeset

* Add additional tests

* Add Vue compatibility

* Svelte

* Update .changeset/tall-bobcats-trade.md

Co-authored-by: Mateusz Burzyński <[email protected]>

---------

Co-authored-by: Mateusz Burzyński <[email protected]>
  • Loading branch information
davidkpiano and Andarist authored Apr 17, 2024
1 parent 9118720 commit 5aa6eb0
Show file tree
Hide file tree
Showing 10 changed files with 306 additions and 10 deletions.
35 changes: 35 additions & 0 deletions .changeset/tall-bobcats-trade.md
Original file line number Diff line number Diff line change
@@ -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 (
<div>
<button onClick={() => store.send({ type: 'inc' })}>{count}</button>
</div>
);
}
```
9 changes: 7 additions & 2 deletions packages/xstate-react/src/useSelector.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,15 @@ function defaultCompare<T>(a: T, b: T) {
return a === b;
}

export function useSelector<TActor extends AnyActorRef | undefined, T>(
export function useSelector<
TActor extends Pick<AnyActorRef, 'subscribe' | 'getSnapshot'> | undefined,
T
>(
actor: TActor,
selector: (
emitted: TActor extends AnyActorRef ? SnapshotFrom<TActor> : undefined
snapshot: TActor extends { getSnapshot(): infer TSnapshot }
? TSnapshot
: undefined
) => T,
compare: (a: T, b: T) => boolean = defaultCompare
): T {
Expand Down
6 changes: 6 additions & 0 deletions packages/xstate-store/src/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
28 changes: 28 additions & 0 deletions packages/xstate-store/test/UseActor.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
<template>
<div data-testid="count">{{ snapshot.context.count }}</div>
<button data-testid="increment" @click="send({ type: 'inc' })">
Increment
</button>
</template>

<script lang="ts">
import { defineComponent } from 'vue';
import { useActor } from '@xstate/vue';
import { fromStore } from '../src/index.ts';
export default defineComponent({
emits: ['rerender'],
setup() {
const { snapshot, send } = useActor(fromStore({
count: 0
}, {
inc: (ctx) => ({ count: ctx.count + 1 })
}))
snapshot.value.context.count satisfies number;
return { snapshot, send };
}
});
</script>
29 changes: 29 additions & 0 deletions packages/xstate-store/test/UseActorRef.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
<template>
<div data-testid="count">{{ count }}</div>
<button data-testid="increment" @click="actorRef.send({ type: 'inc' })">
Increment
</button>
</template>

<script lang="ts">
import { Ref, defineComponent } from 'vue';
import { useActorRef, useSelector } from '@xstate/vue';
import { fromStore } from '../src/index.ts';
export default defineComponent({
emits: ['rerender'],
setup() {
const actorRef = useActorRef(fromStore({
count: 0
}, {
inc: (ctx) => ({ count: ctx.count + 1 })
}))
const count = useSelector(actorRef, s => s.context.count)
count satisfies Ref<number>;
return { count, actorRef };
}
});
</script>
29 changes: 29 additions & 0 deletions packages/xstate-store/test/UseSelector.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
<template>
<div data-testid="count">{{ count }}</div>
<button data-testid="increment" @click="store.send({ type: 'inc' })">
Increment
</button>
</template>

<script lang="ts">
import { Ref, defineComponent } from 'vue';
import { useSelector } from '@xstate/vue';
import { createStore } from '../src/index.ts';
export default defineComponent({
emits: ['rerender'],
setup() {
const store = createStore({
count: 0
}, {
inc: (ctx) => ({ count: ctx.count + 1 })
})
const count = useSelector(store, (state) => state.context.count);
count satisfies Ref<number>;
return { store, count };
}
});
</script>
122 changes: 121 additions & 1 deletion packages/xstate-store/test/react.test.tsx
Original file line number Diff line number Diff line change
@@ -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', () => {
Expand Down Expand Up @@ -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 (
<div
data-testid="count"
onClick={() => {
store.send({ type: 'inc' });
}}
>
{count}
</div>
);
};

render(<Counter />);

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 (
<div
data-testid="count"
onClick={() => {
send({ type: 'inc' });
}}
>
{snapshot.context.count}
</div>
);
};

render(<Counter />);

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 (
<div
data-testid="count"
onClick={() => {
actorRef.send({ type: 'inc' });
}}
>
{count}
</div>
);
};

render(<Counter />);

const countDiv = screen.getByTestId('count');

expect(countDiv.textContent).toEqual('0');

fireEvent.click(countDiv);

expect(countDiv.textContent).toEqual('1');
});
34 changes: 34 additions & 0 deletions packages/xstate-store/test/vue.test.ts
Original file line number Diff line number Diff line change
@@ -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');
});
13 changes: 10 additions & 3 deletions packages/xstate-svelte/src/useSelector.ts
Original file line number Diff line number Diff line change
@@ -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<T>(a: T, b: T) {
return a === b;
}

export const useSelector = <TActor extends ActorRef<any, any>, T>(
export const useSelector = <
TActor extends Pick<AnyActorRef, 'getSnapshot' | 'subscribe'>,
T
>(
actor: TActor,
selector: (snapshot: SnapshotFrom<TActor>) => T,
selector: (
snapshot: TActor extends { getSnapshot(): infer TSnapshot }
? TSnapshot
: undefined
) => T,
compare: (a: T, b: T) => boolean = defaultCompare
) => {
let sub: Subscription;
Expand Down
11 changes: 7 additions & 4 deletions packages/xstate-vue/src/useSelector.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { Ref, isRef, shallowRef, watch } from 'vue';
import { ActorRef, SnapshotFrom } from 'xstate';
import { AnyActorRef } from 'xstate';

function defaultCompare<T>(a: T, b: T) {
return a === b;
Expand All @@ -9,11 +9,14 @@ const noop = () => {
/* ... */
};

export function useSelector<TActor extends ActorRef<any, any> | undefined, T>(
export function useSelector<
TActor extends Pick<AnyActorRef, 'getSnapshot' | 'subscribe'> | undefined,
T
>(
actor: TActor | Ref<TActor>,
selector: (
snapshot: TActor extends ActorRef<any, any>
? SnapshotFrom<TActor>
snapshot: TActor extends { getSnapshot(): infer TSnapshot }
? TSnapshot
: undefined
) => T,
compare: (a: T, b: T) => boolean = defaultCompare
Expand Down

0 comments on commit 5aa6eb0

Please sign in to comment.