Skip to content

Add getter api to @xstate/store in main #5191

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 5 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 42 additions & 0 deletions .changeset/fresh-keys-enjoy.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
---
'@xstate/store': minor
---

Added store getters API for computed values derived from context:

```ts
const store = createStore({
context: { count: 2 },
getters: {
doubled: (ctx) => ctx.count * 2,
squared: (ctx) => ctx.count ** 2,
// Can depend on other getters (types can not be inferred, due to circular references)
sum: (ctx, getters: { doubled: number; squared: number }) =>
getters.doubled + getters.squared
},
on: {
inc: (ctx) => ({ count: ctx.count + 1 })
}
});

// Getters are available on store snapshots
var getters = store.getSnapshot().getters;
assert.equal(getters.doubled, 4);
assert.equal(getters.squared, 4);
assert.equal(getters.sum, 8);

// Automatically update when context changes
store.send({ type: 'inc' });
var getters = store.getSnapshot().getters;
assert.equal(getters.doubled, 6);
assert.equal(getters.squared, 36);
assert.equal(getters.sum, 42);
```

Key features:

- Getters recalculate automatically when context changes
- Included in inspection snapshots
- Can depend on other getters via proxy
- Works with Immer producer API
- Full type safety for computed values
67 changes: 42 additions & 25 deletions packages/xstate-store/src/fromStore.ts
Original file line number Diff line number Diff line change
@@ -1,21 +1,28 @@
import { ActorLogic } from 'xstate';
import { createStoreTransition, TransitionsFromEventPayloadMap } from './store';
import { createStoreTransition, computeGetters } from './store';
import {
EventPayloadMap,
StoreContext,
Snapshot,
StoreSnapshot,
EventObject,
ExtractEvents,
StoreAssigner
StoreAssigner,
StoreGetters
} from './types';

type StoreLogic<
TContext extends StoreContext,
TEvent extends EventObject,
TInput,
TEmitted extends EventObject
> = ActorLogic<StoreSnapshot<TContext>, TEvent, TInput, any, TEmitted>;
TEmitted extends EventObject,
TGetters extends Record<string, (context: TContext, getters: any) => any> = {}
> = ActorLogic<
StoreSnapshot<TContext, TGetters>,
TEvent,
TInput,
any,
TEmitted
>;

/**
* An actor logic creator which creates store [actor
Expand All @@ -26,13 +33,15 @@ type StoreLogic<
* that returns context based on input, or the context itself
* @param config.on An object defining the transitions for different event types
* @param config.emits Optional object to define emitted event handlers
* @param config.getters Optional object to define store getters
* @returns An actor logic creator function that creates store actor logic
*/
export function fromStore<
TContext extends StoreContext,
TEventPayloadMap extends EventPayloadMap,
TInput,
TEmitted extends EventPayloadMap
TEmitted extends EventPayloadMap,
TGetters extends Record<string, (context: TContext, getters: any) => any> = {}
>(config: {
context: ((input: TInput) => TContext) | TContext;
on: {
Expand All @@ -47,47 +56,55 @@ export function fromStore<
payload: { type: K } & TEmitted[K]
) => void;
};
getters?: StoreGetters<TContext, TGetters>;
}): StoreLogic<
TContext,
ExtractEvents<TEventPayloadMap>,
TInput,
ExtractEvents<TEmitted>
ExtractEvents<TEmitted>,
TGetters
> {
const initialContext: ((input: TInput) => TContext) | TContext =
config.context;
const transitionsObj: TransitionsFromEventPayloadMap<
TEventPayloadMap,
NoInfer<TContext>,
EventObject
> = config.on;
const initialContext = config.context;
const transitionsObj = config.on;
const getters = config.getters;

const transition = createStoreTransition(transitionsObj);

return {
transition: (snapshot, event, actorScope) => {
const [nextSnapshot, effects] = transition(snapshot, event);
const [newContext, effects] = transition(snapshot.context, event);

const newSnapshot = {
...snapshot,
context: newContext,
getters: computeGetters(newContext, getters)
} as StoreSnapshot<TContext, TGetters>;

for (const effect of effects) {
if (typeof effect === 'function') {
effect();
} else {
actorScope.emit(effect as ExtractEvents<TEmitted>);
actorScope.emit(effect);
}
}

return nextSnapshot;
return newSnapshot;
},
getInitialSnapshot: (_, input: TInput) => {
const context =
typeof initialContext === 'function'
? initialContext(input)
: initialContext;

return {
status: 'active',
context:
typeof initialContext === 'function'
? initialContext(input)
: initialContext,
context,
output: undefined,
error: undefined
} satisfies StoreSnapshot<TContext>;
error: undefined,
getters: computeGetters(context, getters)
} satisfies StoreSnapshot<TContext, TGetters>;
},
getPersistedSnapshot: (s: StoreSnapshot<TContext>) => s,
restoreSnapshot: (s: Snapshot<unknown>) => s as StoreSnapshot<TContext>
getPersistedSnapshot: (s) => s,
restoreSnapshot: (s) => s as StoreSnapshot<TContext, TGetters>
};
}
Loading