Skip to content

Commit

Permalink
[@xstate/store] Store select (#5200)
Browse files Browse the repository at this point in the history
* Export to index

* Add test

* Fix types

* Add changeset

* Update test

* Use existing Subscribable

* Add store.select(…) and update changeset
  • Loading branch information
davidkpiano authored Feb 24, 2025
1 parent 10e0d6d commit 0332a16
Show file tree
Hide file tree
Showing 5 changed files with 336 additions and 19 deletions.
40 changes: 40 additions & 0 deletions .changeset/six-foxes-deny.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
---
'@xstate/store': minor
---

Added selectors to @xstate/store that enable efficient state selection and subscription:

- `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 via `store.select(selector, equalityFn)`

```ts
const store = createStore({
context: {
position: { x: 0, y: 0 },
user: { name: 'John', age: 30 }
},
on: {
positionUpdated: (
context,
event: { position: { x: number; y: number } }
) => ({
...context,
position: event.position
})
}
});

const position = store.select((state) => state.context.position);

position.get(); // { x: 0, y: 0 }

position.subscribe((position) => {
console.log(position);
});

store.trigger.positionUpdated({ x: 100, y: 200 });
// Logs: { x: 100, y: 200 }
```
243 changes: 243 additions & 0 deletions packages/xstate-store/src/select.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,243 @@
import { createStore } from './index';

interface TestContext {
user: {
name: string;
age: number;
};
settings: {
theme: 'light' | 'dark';
notifications: boolean;
};
}

describe('select', () => {
it('should get current value', () => {
const store = createStore({
context: {
user: { name: 'John', age: 30 },
settings: { theme: 'dark', notifications: true }
} as TestContext,
on: {
UPDATE_NAME: (context, event: { name: string }) => ({
...context,
user: { ...context.user, name: event.name }
}),
UPDATE_THEME: (context, event: { theme: 'light' | 'dark' }) => ({
...context,
settings: { ...context.settings, theme: event.theme }
})
}
});

const name = store.select((state) => state.user.name).get();
expect(name).toBe('John');
});

it('should subscribe to changes', () => {
const store = createStore({
context: {
user: { name: 'John', age: 30 },
settings: { theme: 'dark', notifications: true }
} as TestContext,
on: {
UPDATE_NAME: (context, event: { name: string }) => ({
...context,
user: { ...context.user, name: event.name }
}),
UPDATE_THEME: (context, event: { theme: 'light' | 'dark' }) => ({
...context,
settings: { ...context.settings, theme: event.theme }
})
}
});

const callback = jest.fn();
store.select((state) => state.user.name).subscribe(callback);
store.send({ type: 'UPDATE_NAME', name: 'Jane' });

expect(callback).toHaveBeenCalledTimes(1);
expect(callback).toHaveBeenCalledWith('Jane');
});

it('should not notify if selected value has not changed', () => {
const store = createStore({
context: {
user: { name: 'John', age: 30 },
settings: { theme: 'dark', notifications: true }
} as TestContext,
on: {
UPDATE_NAME: (context, event: { name: string }) => ({
...context,
user: { ...context.user, name: event.name }
}),
UPDATE_THEME: (context, event: { theme: 'light' | 'dark' }) => ({
...context,
settings: { ...context.settings, theme: event.theme }
})
}
});

const callback = jest.fn();
store.select((state) => state.user.name).subscribe(callback);
store.send({ type: 'UPDATE_THEME', theme: 'light' });

expect(callback).not.toHaveBeenCalled();
});

it('should support custom equality function', () => {
const store = createStore({
context: {
user: { name: 'John', age: 30 },
settings: { theme: 'dark', notifications: true }
} as TestContext,
on: {
UPDATE_NAME: (context, event: { name: string }) => ({
...context,
user: { ...context.user, name: event.name }
}),
UPDATE_THEME: (context, event: { theme: 'light' | 'dark' }) => ({
...context,
settings: { ...context.settings, theme: event.theme }
})
}
});

const callback = jest.fn();
const selector = (context: TestContext) => ({
name: context.user.name,
theme: context.settings.theme
});
const equalityFn = (a: { name: string }, b: { name: string }) =>
a.name === b.name; // Only compare names

store.select(selector, equalityFn).subscribe(callback);

store.send({ type: 'UPDATE_THEME', theme: 'light' });
expect(callback).not.toHaveBeenCalled();

store.send({ type: 'UPDATE_NAME', name: 'Jane' });
expect(callback).toHaveBeenCalledTimes(1);
});

it('should unsubscribe correctly', () => {
const store = createStore({
context: {
user: { name: 'John', age: 30 },
settings: { theme: 'dark', notifications: true }
} as TestContext,
on: {
UPDATE_NAME: (context, event: { name: string }) => ({
...context,
user: { ...context.user, name: event.name }
}),
UPDATE_THEME: (context, event: { theme: 'light' | 'dark' }) => ({
...context,
settings: { ...context.settings, theme: event.theme }
})
}
});

const callback = jest.fn();
const subscription = store
.select((state) => state.user.name)
.subscribe(callback);
subscription.unsubscribe();
store.send({ type: 'UPDATE_NAME', name: 'Jane' });

expect(callback).not.toHaveBeenCalled();
});

it('should handle updates with multiple subscribers', () => {
interface PositionContext {
position: {
x: number;
y: number;
};
}

const store = createStore({
context: {
position: { x: 0, y: 0 },
user: { name: 'John', age: 30 }
} as PositionContext,
on: {
positionUpdated: (
context,
event: { position: { x: number; y: number } }
) => ({
...context,
position: event.position
}),
userUpdated: (
context,
event: { user: { name: string; age: number } }
) => ({
...context,
user: event.user
})
}
});

// Mock DOM manipulation callback
const renderCallback = jest.fn();
store
.select((state) => state.position)
.subscribe((position) => {
renderCallback(position);
});

// Mock logger callback for x position only
const loggerCallback = jest.fn();
store
.select((state) => state.position.x)
.subscribe((x) => {
loggerCallback(x);
});

// Simulate position update
store.trigger.positionUpdated({
position: { x: 100, y: 200 }
});

// Verify render callback received full position update
expect(renderCallback).toHaveBeenCalledTimes(1);
expect(renderCallback).toHaveBeenCalledWith({ x: 100, y: 200 });

// Verify logger callback received only x position
expect(loggerCallback).toHaveBeenCalledTimes(1);
expect(loggerCallback).toHaveBeenCalledWith(100);

// Simulate another update
store.trigger.positionUpdated({
position: { x: 150, y: 300 }
});

expect(renderCallback).toHaveBeenCalledTimes(2);
expect(renderCallback).toHaveBeenLastCalledWith({ x: 150, y: 300 });
expect(loggerCallback).toHaveBeenCalledTimes(2);
expect(loggerCallback).toHaveBeenLastCalledWith(150);

// Simulate changing only the y position
store.trigger.positionUpdated({
position: { x: 150, y: 400 }
});

expect(renderCallback).toHaveBeenCalledTimes(3);
expect(renderCallback).toHaveBeenLastCalledWith({ x: 150, y: 400 });

// loggerCallback should not have been called
expect(loggerCallback).toHaveBeenCalledTimes(2);

// Simulate changing only the user
store.trigger.userUpdated({
user: { name: 'Jane', age: 25 }
});

// renderCallback should not have been called
expect(renderCallback).toHaveBeenCalledTimes(3);

// loggerCallback should not have been called
expect(loggerCallback).toHaveBeenCalledTimes(2);
});
});
44 changes: 25 additions & 19 deletions packages/xstate-store/src/store.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { toObserver } from './toObserver';
import {
EnqueueObject,
EventObject,
Expand All @@ -13,30 +14,15 @@ import {
StoreEffect,
StoreInspectionEvent,
StoreProducerAssigner,
StoreSnapshot
StoreSnapshot,
Selector,
Selection
} from './types';

const symbolObservable: typeof Symbol.observable = (() =>
(typeof Symbol === 'function' && Symbol.observable) ||
'@@observable')() as any;

function toObserver<T>(
nextHandler?: Observer<T> | ((value: T) => void),
errorHandler?: (error: any) => void,
completionHandler?: () => void
): Observer<T> {
const isObserver = typeof nextHandler === 'object';
const self = isObserver ? nextHandler : undefined;

return {
next: (isObserver ? nextHandler.next : nextHandler)?.bind(self),
error: (isObserver ? nextHandler.error : errorHandler)?.bind(self),
complete: (isObserver ? nextHandler.complete : completionHandler)?.bind(
self
)
};
}

/**
* Updates a context object using a recipe function.
*
Expand Down Expand Up @@ -215,7 +201,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
18 changes: 18 additions & 0 deletions packages/xstate-store/src/toObserver.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { Observer } from './types';

export function toObserver<T>(
nextHandler?: Observer<T> | ((value: T) => void),
errorHandler?: (error: any) => void,
completionHandler?: () => void
): Observer<T> {
const isObserver = typeof nextHandler === 'object';
const self = isObserver ? nextHandler : undefined;

return {
next: (isObserver ? nextHandler.next : nextHandler)?.bind(self),
error: (isObserver ? nextHandler.error : errorHandler)?.bind(self),
complete: (isObserver ? nextHandler.complete : completionHandler)?.bind(
self
)
};
}
10 changes: 10 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 StoreConfig<
Expand Down Expand Up @@ -330,3 +334,9 @@ export type Cast<A, B> = A extends B ? A : B;
export type EventMap<TEvent extends EventObject> = {
[E in TEvent as E['type']]: E;
};

export type Selector<TContext, TSelected> = (context: TContext) => TSelected;

export interface Selection<TSelected> extends Subscribable<TSelected> {
get: () => TSelected;
}

0 comments on commit 0332a16

Please sign in to comment.