From 0979f8dda23c2cb5e2d5f6704b9bedeec4d3fcbe Mon Sep 17 00:00:00 2001 From: David Khourshid Date: Thu, 20 Feb 2025 10:01:00 -0500 Subject: [PATCH] =?UTF-8?q?Add=20store.select(=E2=80=A6)=20and=20update=20?= =?UTF-8?q?changeset?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .changeset/six-foxes-deny.md | 13 +++++---- packages/xstate-store/src/index.ts | 1 - packages/xstate-store/src/select.test.ts | 32 ++++++++++++---------- packages/xstate-store/src/select.ts | 34 ------------------------ packages/xstate-store/src/store.ts | 26 ++++++++++++++++-- packages/xstate-store/src/types.ts | 4 +++ 6 files changed, 54 insertions(+), 56 deletions(-) delete mode 100644 packages/xstate-store/src/select.ts diff --git a/.changeset/six-foxes-deny.md b/.changeset/six-foxes-deny.md index 75e31ebdf2..bc17ee5bd4 100644 --- a/.changeset/six-foxes-deny.md +++ b/.changeset/six-foxes-deny.md @@ -4,11 +4,11 @@ Added selectors to @xstate/store that enable efficient state selection and subscription: -- `select(store, selector)` function to create a "selector" entity where you can: +- `store.select(selector)` function to create a "selector" entity where you can: - Get current value with `.get()` - Subscribe to changes with `.subscribe(callback)` - Only notify subscribers when selected value actually changes - - Support custom equality functions for fine-grained control over updates + - Support custom equality functions for fine-grained control over updates via `store.select(selector, equalityFn)` ```ts const store = createStore({ @@ -17,14 +17,17 @@ const store = createStore({ user: { name: 'John', age: 30 } }, on: { - setPosition: (context, event: { position: { x: number; y: number } }) => ({ + positionUpdated: ( + context, + event: { position: { x: number; y: number } } + ) => ({ ...context, position: event.position }) } }); -const position = select(store, (state) => state.context.position); +const position = store.select((state) => state.context.position); position.get(); // { x: 0, y: 0 } @@ -32,6 +35,6 @@ position.subscribe((position) => { console.log(position); }); -store.trigger.setPosition({ x: 100, y: 200 }); +store.trigger.positionUpdated({ x: 100, y: 200 }); // Logs: { x: 100, y: 200 } ``` diff --git a/packages/xstate-store/src/index.ts b/packages/xstate-store/src/index.ts index a707abf7d3..15caa34d3f 100644 --- a/packages/xstate-store/src/index.ts +++ b/packages/xstate-store/src/index.ts @@ -1,5 +1,4 @@ export { shallowEqual } from './shallowEqual'; export { fromStore } from './fromStore'; export { createStore, createStoreWithProducer } from './store'; -export { select } from './select'; export * from './types'; diff --git a/packages/xstate-store/src/select.test.ts b/packages/xstate-store/src/select.test.ts index feaf757b16..b921844eea 100644 --- a/packages/xstate-store/src/select.test.ts +++ b/packages/xstate-store/src/select.test.ts @@ -1,4 +1,4 @@ -import { createStore, select } from './index'; +import { createStore } from './index'; interface TestContext { user: { @@ -30,7 +30,7 @@ describe('select', () => { } }); - const name = select(store, (state) => state.user.name).get(); + const name = store.select((state) => state.user.name).get(); expect(name).toBe('John'); }); @@ -53,7 +53,7 @@ describe('select', () => { }); const callback = jest.fn(); - select(store, (state) => state.user.name).subscribe(callback); + store.select((state) => state.user.name).subscribe(callback); store.send({ type: 'UPDATE_NAME', name: 'Jane' }); expect(callback).toHaveBeenCalledTimes(1); @@ -79,7 +79,7 @@ describe('select', () => { }); const callback = jest.fn(); - select(store, (state) => state.user.name).subscribe(callback); + store.select((state) => state.user.name).subscribe(callback); store.send({ type: 'UPDATE_THEME', theme: 'light' }); expect(callback).not.toHaveBeenCalled(); @@ -111,7 +111,7 @@ describe('select', () => { const equalityFn = (a: { name: string }, b: { name: string }) => a.name === b.name; // Only compare names - select(store, selector, equalityFn).subscribe(callback); + store.select(selector, equalityFn).subscribe(callback); store.send({ type: 'UPDATE_THEME', theme: 'light' }); expect(callback).not.toHaveBeenCalled(); @@ -139,9 +139,9 @@ describe('select', () => { }); const callback = jest.fn(); - const subscription = select(store, (state) => state.user.name).subscribe( - callback - ); + const subscription = store + .select((state) => state.user.name) + .subscribe(callback); subscription.unsubscribe(); store.send({ type: 'UPDATE_NAME', name: 'Jane' }); @@ -181,15 +181,19 @@ describe('select', () => { // Mock DOM manipulation callback const renderCallback = jest.fn(); - select(store, (state) => state.position).subscribe((position) => { - renderCallback(position); - }); + store + .select((state) => state.position) + .subscribe((position) => { + renderCallback(position); + }); // Mock logger callback for x position only const loggerCallback = jest.fn(); - select(store, (state) => state.position.x).subscribe((x) => { - loggerCallback(x); - }); + store + .select((state) => state.position.x) + .subscribe((x) => { + loggerCallback(x); + }); // Simulate position update store.trigger.positionUpdated({ diff --git a/packages/xstate-store/src/select.ts b/packages/xstate-store/src/select.ts deleted file mode 100644 index 8ae3836a78..0000000000 --- a/packages/xstate-store/src/select.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { toObserver } from './toObserver'; -import type { - Store, - StoreContext, - EventObject, - Selector, - Selection -} from './types'; -export function select< - TContext extends StoreContext, - TEvent extends EventObject, - TEmitted extends EventObject, - TSelected ->( - store: Store, - selector: Selector, - equalityFn: (a: TSelected, b: TSelected) => boolean = Object.is -): Selection { - return { - subscribe: (observerOrFn) => { - const observer = toObserver(observerOrFn); - let previousSelected = selector(store.getSnapshot().context); - - return store.subscribe((snapshot) => { - const nextSelected = selector(snapshot.context); - if (!equalityFn(previousSelected, nextSelected)) { - previousSelected = nextSelected; - observer.next?.(nextSelected); - } - }); - }, - get: () => selector(store.getSnapshot().context) - }; -} diff --git a/packages/xstate-store/src/store.ts b/packages/xstate-store/src/store.ts index 13c5804de0..a2e3e75307 100644 --- a/packages/xstate-store/src/store.ts +++ b/packages/xstate-store/src/store.ts @@ -13,7 +13,9 @@ import { StoreEffect, StoreInspectionEvent, StoreProducerAssigner, - StoreSnapshot + StoreSnapshot, + Selector, + Selection } from './types'; const symbolObservable: typeof Symbol.observable = (() => @@ -195,7 +197,27 @@ function createStoreCore< }); }; } - }) + }), + select( + selector: Selector, + equalityFn: (a: TSelected, b: TSelected) => boolean = Object.is + ): Selection { + return { + subscribe: (observerOrFn) => { + const observer = toObserver(observerOrFn); + let previousSelected = selector(this.getSnapshot().context); + + return this.subscribe((snapshot) => { + const nextSelected = selector(snapshot.context); + if (!equalityFn(previousSelected, nextSelected)) { + previousSelected = nextSelected; + observer.next?.(nextSelected); + } + }); + }, + get: () => selector(this.getSnapshot().context) + }; + } }; return store; diff --git a/packages/xstate-store/src/types.ts b/packages/xstate-store/src/types.ts index efb283d656..87b250193c 100644 --- a/packages/xstate-store/src/types.ts +++ b/packages/xstate-store/src/types.ts @@ -111,6 +111,10 @@ export interface Store< ? () => Omit : (eventPayload: Omit) => void; }; + select( + selector: Selector, + equalityFn?: (a: TSelected, b: TSelected) => boolean + ): Selection; } export type IsEmptyObject = T extends Record ? true : false;