Skip to content

Commit

Permalink
Add store.select(…) and update changeset
Browse files Browse the repository at this point in the history
  • Loading branch information
davidkpiano committed Feb 20, 2025
1 parent c2263f8 commit 0979f8d
Show file tree
Hide file tree
Showing 6 changed files with 54 additions and 56 deletions.
13 changes: 8 additions & 5 deletions .changeset/six-foxes-deny.md
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand All @@ -17,21 +17,24 @@ 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 }

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 }
```
1 change: 0 additions & 1 deletion packages/xstate-store/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
export { shallowEqual } from './shallowEqual';
export { fromStore } from './fromStore';
export { createStore, createStoreWithProducer } from './store';
export { select } from './select';
export * from './types';
32 changes: 18 additions & 14 deletions packages/xstate-store/src/select.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { createStore, select } from './index';
import { createStore } from './index';

interface TestContext {
user: {
Expand Down Expand Up @@ -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');
});

Expand All @@ -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);
Expand All @@ -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();
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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' });

Expand Down Expand Up @@ -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({
Expand Down
34 changes: 0 additions & 34 deletions packages/xstate-store/src/select.ts

This file was deleted.

26 changes: 24 additions & 2 deletions packages/xstate-store/src/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,9 @@ import {
StoreEffect,
StoreInspectionEvent,
StoreProducerAssigner,
StoreSnapshot
StoreSnapshot,
Selector,
Selection
} from './types';

const symbolObservable: typeof Symbol.observable = (() =>
Expand Down Expand Up @@ -195,7 +197,27 @@ function createStoreCore<
});
};
}
})
}),
select<TSelected>(
selector: Selector<TContext, TSelected>,
equalityFn: (a: TSelected, b: TSelected) => boolean = Object.is
): Selection<TSelected> {
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;
Expand Down
4 changes: 4 additions & 0 deletions packages/xstate-store/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,10 @@ export interface Store<
? () => Omit<E, 'type'>
: (eventPayload: Omit<E, 'type'>) => void;
};
select<TSelected>(
selector: Selector<TContext, TSelected>,
equalityFn?: (a: TSelected, b: TSelected) => boolean
): Selection<TSelected>;
}

export type IsEmptyObject<T> = T extends Record<string, never> ? true : false;
Expand Down

0 comments on commit 0979f8d

Please sign in to comment.