diff --git a/.changeset/five-walls-approve.md b/.changeset/five-walls-approve.md new file mode 100644 index 0000000000..9193469d5c --- /dev/null +++ b/.changeset/five-walls-approve.md @@ -0,0 +1,27 @@ +--- +'@xstate/store': major +--- + +The `createStore` function now only accepts a single configuration object argument. This is a breaking change that simplifies the API and aligns with the configuration pattern used throughout XState. + +```ts +// Before +// createStore( +// { +// count: 0 +// }, +// { +// increment: (context) => ({ count: context.count + 1 }) +// } +// ); + +// After +createStore({ + context: { + count: 0 + }, + on: { + increment: (context) => ({ count: context.count + 1 }) + } +}); +``` diff --git a/.changeset/great-candles-rule.md b/.changeset/great-candles-rule.md new file mode 100644 index 0000000000..c3233a0b2d --- /dev/null +++ b/.changeset/great-candles-rule.md @@ -0,0 +1,24 @@ +--- +'@xstate/store': major +--- + +You can now enqueue effects in state transitions. + +```ts +const store = createStore({ + context: { + count: 0 + }, + on: { + incrementDelayed: (context, event, enq) => { + enq.effect(async () => { + await new Promise((resolve) => setTimeout(resolve, 1000)); + store.send({ type: 'increment' }); + }); + + return context; + }, + increment: (context) => ({ count: context.count + 1 }) + } +}); +``` diff --git a/.changeset/mean-taxis-jump.md b/.changeset/mean-taxis-jump.md new file mode 100644 index 0000000000..54bd1c7420 --- /dev/null +++ b/.changeset/mean-taxis-jump.md @@ -0,0 +1,17 @@ +--- +'@xstate/store': major +--- + +The `fromStore(config)` function now only supports a single config object argument. + +```ts +const storeLogic = fromStore({ + context: (input: { initialCount: number }) => ({ count: input.initialCount }), + on: { + inc: (ctx, ev: { by: number }) => ({ + ...ctx, + count: ctx.count + ev.by + }) + } +}); +``` diff --git a/.changeset/quick-bears-swim.md b/.changeset/quick-bears-swim.md new file mode 100644 index 0000000000..830fc10e16 --- /dev/null +++ b/.changeset/quick-bears-swim.md @@ -0,0 +1,24 @@ +--- +'@xstate/store': minor +--- + +Added `store.trigger` API for sending events with a fluent interface: + +```ts +const store = createStore({ + context: { count: 0 }, + on: { + increment: (ctx, event: { by: number }) => ({ + count: ctx.count + event.by + }) + } +}); + +// Instead of manually constructing event objects: +store.send({ type: 'increment', by: 5 }); + +// You can now use the fluent trigger API: +store.trigger.increment({ by: 5 }); +``` + +The `trigger` API provides full type safety for event names and payloads, making it easier and safer to send events to the store. diff --git a/.changeset/spotty-moose-joke.md b/.changeset/spotty-moose-joke.md new file mode 100644 index 0000000000..1a6fbf15e5 --- /dev/null +++ b/.changeset/spotty-moose-joke.md @@ -0,0 +1,32 @@ +--- +'@xstate/store': major +--- + +The `createStoreWithProducer(…)` function now only accepts two arguments: a `producer` and a config (`{ context, on }`) object. + +```ts +// Before +// createStoreWithProducer( +// producer, +// { +// count: 0 +// }, +// { +// increment: (context) => { +// context.count++; +// } +// } +// ); + +// After +createStoreWithProducer(producer, { + context: { + count: 0 + }, + on: { + increment: (context) => { + context.count++; + } + } +}); +``` diff --git a/.changeset/thick-paws-invite.md b/.changeset/thick-paws-invite.md new file mode 100644 index 0000000000..ea9cd7668e --- /dev/null +++ b/.changeset/thick-paws-invite.md @@ -0,0 +1,19 @@ +--- +'@xstate/store': major +--- + +Only complete assigner functions that replace the `context` fully are supported. This is a breaking change that simplifies the API and provides more type safety. + +```diff +const store = createStore({ + context: { + items: [], + count: 0 + }, + on: { +- increment: { count: (context) => context.count + 1 } +- increment: (context) => ({ count: context.count + 1 }) ++ increment: (context) => ({ ...context, count: context.count + 1 }) + } +}) +``` diff --git a/.changeset/wise-bikes-leave.md b/.changeset/wise-bikes-leave.md new file mode 100644 index 0000000000..0f5b7a9e0b --- /dev/null +++ b/.changeset/wise-bikes-leave.md @@ -0,0 +1,24 @@ +--- +'@xstate/store': major +--- + +Emitted event types are now specified in functions on the `emits` property of the store definition: + +```ts +const store = createStore({ + // … + emits: { + increased: (payload: { upBy: number }) => { + // You can execute a side-effect here + // or leave it empty + } + }, + on: { + inc: (ctx, ev: { by: number }, enq) => { + enq.emit.increased({ upBy: ev.by }); + + // … + } + } +}); +``` diff --git a/packages/xstate-store/src/fromStore.ts b/packages/xstate-store/src/fromStore.ts index 5469e4b9ea..551a48335c 100644 --- a/packages/xstate-store/src/fromStore.ts +++ b/packages/xstate-store/src/fromStore.ts @@ -1,4 +1,4 @@ -import { ActorLogic, Cast } from 'xstate'; +import { ActorLogic } from 'xstate'; import { createStoreTransition, TransitionsFromEventPayloadMap } from './store'; import { EventPayloadMap, @@ -6,9 +6,8 @@ import { Snapshot, StoreSnapshot, EventObject, - ExtractEventsFromPayloadMap, - StoreAssigner, - StorePropertyAssigner + ExtractEvents, + StoreAssigner } from './types'; type StoreLogic< @@ -18,34 +17,6 @@ type StoreLogic< TEmitted extends EventObject > = ActorLogic, TEvent, TInput, any, TEmitted>; -/** - * An actor logic creator which creates store [actor - * logic](https://stately.ai/docs/actors#actor-logic) for use with XState. - * - * @param initialContext The initial context for the store, either a function - * that returns context based on input, or the context itself - * @param transitions The transitions object defining how the context updates - * due to events - * @returns An actor logic creator function that creates store actor logic - */ -export function fromStore< - TContext extends StoreContext, - TEventPayloadMap extends EventPayloadMap, - TInput ->( - initialContext: ((input: TInput) => TContext) | TContext, - transitions: TransitionsFromEventPayloadMap< - TEventPayloadMap, - NoInfer, - EventObject - > -): StoreLogic< - TContext, - ExtractEventsFromPayloadMap, - TInput, - EventObject ->; - /** * An actor logic creator which creates store [actor * logic](https://stately.ai/docs/actors#actor-logic) for use with XState. @@ -54,97 +25,54 @@ export function fromStore< * @param config.context The initial context for the store, either a function * that returns context based on input, or the context itself * @param config.on An object defining the transitions for different event types - * @param config.types Optional object to define custom event types + * @param config.emits Optional object to define emitted event handlers * @returns An actor logic creator function that creates store actor logic */ export function fromStore< TContext extends StoreContext, TEventPayloadMap extends EventPayloadMap, TInput, - TTypes extends { emitted?: EventObject } ->( - config: { - context: ((input: TInput) => TContext) | TContext; - on: { - [K in keyof TEventPayloadMap & string]: - | StoreAssigner< - NoInfer, - { type: K } & TEventPayloadMap[K], - Cast - > - | StorePropertyAssigner< - NoInfer, - { type: K } & TEventPayloadMap[K], - Cast - >; - }; - } & { types?: TTypes } -): StoreLogic< - TContext, - ExtractEventsFromPayloadMap, - TInput, - TTypes['emitted'] extends EventObject ? TTypes['emitted'] : EventObject ->; -export function fromStore< - TContext extends StoreContext, - TEventPayloadMap extends EventPayloadMap, - TInput, - TTypes extends { emitted?: EventObject } ->( - initialContextOrObj: - | ((input: TInput) => TContext) - | TContext - | ({ - context: ((input: TInput) => TContext) | TContext; - on: { - [K in keyof TEventPayloadMap & string]: - | StoreAssigner< - NoInfer, - { type: K } & TEventPayloadMap[K], - Cast - > - | StorePropertyAssigner< - NoInfer, - { type: K } & TEventPayloadMap[K], - Cast - >; - }; - } & { types?: TTypes }), - transitions?: TransitionsFromEventPayloadMap< - TEventPayloadMap, - NoInfer, - EventObject - > -): StoreLogic< + TEmitted extends EventPayloadMap +>(config: { + context: ((input: TInput) => TContext) | TContext; + on: { + [K in keyof TEventPayloadMap & string]: StoreAssigner< + NoInfer, + { type: K } & TEventPayloadMap[K], + ExtractEvents + >; + }; + emits?: { + [K in keyof TEmitted & string]: ( + payload: { type: K } & TEmitted[K] + ) => void; + }; +}): StoreLogic< TContext, - ExtractEventsFromPayloadMap, + ExtractEvents, TInput, - TTypes['emitted'] extends EventObject ? TTypes['emitted'] : EventObject + ExtractEvents > { - let initialContext: ((input: TInput) => TContext) | TContext; - let transitionsObj: TransitionsFromEventPayloadMap< + const initialContext: ((input: TInput) => TContext) | TContext = + config.context; + const transitionsObj: TransitionsFromEventPayloadMap< TEventPayloadMap, NoInfer, EventObject - >; - - if ( - typeof initialContextOrObj === 'object' && - 'context' in initialContextOrObj - ) { - initialContext = initialContextOrObj.context; - transitionsObj = initialContextOrObj.on; - } else { - initialContext = initialContextOrObj; - transitionsObj = transitions!; - } + > = config.on; const transition = createStoreTransition(transitionsObj); return { transition: (snapshot, event, actorScope) => { - const [nextSnapshot, emittedEvents] = transition(snapshot, event); + const [nextSnapshot, effects] = transition(snapshot, event); - emittedEvents.forEach(actorScope.emit); + for (const effect of effects) { + if (typeof effect === 'function') { + effect(); + } else { + actorScope.emit(effect as ExtractEvents); + } + } return nextSnapshot; }, diff --git a/packages/xstate-store/src/store.ts b/packages/xstate-store/src/store.ts index 6a651d58be..57ff25ca1e 100644 --- a/packages/xstate-store/src/store.ts +++ b/packages/xstate-store/src/store.ts @@ -1,19 +1,17 @@ import { - Cast, EnqueueObject, EventObject, EventPayloadMap, - ExtractEventsFromPayloadMap, + ExtractEvents, InteropSubscribable, Observer, Recipe, Store, StoreAssigner, - StoreCompleteAssigner, StoreContext, + StoreEffect, StoreInspectionEvent, - StorePartialAssigner, - StorePropertyAssigner, + StoreProducerAssigner, StoreSnapshot } from './types'; @@ -38,6 +36,13 @@ function toObserver( }; } +/** + * Updates a context object using a recipe function. + * + * @param context - The current context + * @param recipe - A function that describes how to update the context + * @returns The updated context + */ function setter( context: TContext, recipe: Recipe @@ -57,24 +62,18 @@ function createStoreCore< >( initialContext: TContext, transitions: { - [K in keyof TEventPayloadMap & string]: - | StoreAssigner< - NoInfer, - { type: K } & TEventPayloadMap[K], - TEmitted - > - | StorePropertyAssigner< - NoInfer, - { type: K } & TEventPayloadMap[K], - TEmitted - >; + [K in keyof TEventPayloadMap & string]: StoreAssigner< + NoInfer, + { type: K } & TEventPayloadMap[K], + TEmitted + >; }, - updater?: ( + producer?: ( context: NoInfer, - recipe: (context: NoInfer) => NoInfer + recipe: (context: NoInfer) => void ) => NoInfer -): Store, TEmitted> { - type StoreEvent = ExtractEventsFromPayloadMap; +): Store, TEmitted> { + type StoreEvent = ExtractEvents; let observers: Set>> | undefined; let listeners: Map> | undefined; const initialSnapshot: StoreSnapshot = { @@ -96,11 +95,11 @@ function createStoreCore< } }; - const transition = createStoreTransition(transitions, updater); + const transition = createStoreTransition(transitions, producer); function receive(event: StoreEvent) { - let emitted: TEmitted[]; - [currentSnapshot, emitted] = transition(currentSnapshot, event); + let effects: StoreEffect[]; + [currentSnapshot, effects] = transition(currentSnapshot, event); inspectionObservers.get(store)?.forEach((observer) => { observer.next?.({ @@ -114,7 +113,13 @@ function createStoreCore< observers?.forEach((o) => o.next?.(currentSnapshot)); - emitted.forEach(emit); + for (const effect of effects) { + if (typeof effect === 'function') { + effect(); + } else { + emit(effect); + } + } } const store: Store = { @@ -196,7 +201,17 @@ function createStoreCore< return inspectionObservers.get(store)?.delete(observer); } }; - } + }, + trigger: new Proxy({} as Store['trigger'], { + get: (_, eventType: string) => { + return (payload: any) => { + store.send({ + type: eventType, + ...payload + }); + }; + } + }) }; return store; @@ -207,23 +222,41 @@ export type TransitionsFromEventPayloadMap< TContext extends StoreContext, TEmitted extends EventObject > = { - [K in keyof TEventPayloadMap & string]: - | StoreAssigner< - TContext, - { - type: K; - } & TEventPayloadMap[K], - TEmitted - > - | StorePropertyAssigner< - TContext, - { - type: K; - } & TEventPayloadMap[K], - TEmitted - >; + [K in keyof TEventPayloadMap & string]: StoreAssigner< + TContext, + { + type: K; + } & TEventPayloadMap[K], + TEmitted + >; }; +type CreateStoreParameterTypes< + TContext extends StoreContext, + TEventPayloadMap extends EventPayloadMap, + TEmitted extends EventPayloadMap +> = [ + definition: { + context: TContext; + emits?: { + [K in keyof TEmitted & string]: (payload: TEmitted[K]) => void; + }; + on: { + [K in keyof TEventPayloadMap & string]: StoreAssigner< + NoInfer, + { type: K } & TEventPayloadMap[K], + ExtractEvents + >; + }; + } +]; + +type CreateStoreReturnType< + TContext extends StoreContext, + TEventPayloadMap extends EventPayloadMap, + TEmitted extends EventPayloadMap +> = Store, ExtractEvents>; + /** * Creates a **store** that has its own internal state and can be sent events * that update its internal state based on transitions. @@ -232,16 +265,11 @@ export type TransitionsFromEventPayloadMap< * * ```ts * const store = createStore({ - * types: { - * // ... - * }, * context: { count: 0 }, * on: { - * inc: (context, event: { by: number }) => { - * return { - * count: context.count + event.by - * }; - * } + * inc: (context, event: { by: number }) => ({ + * count: context.count + event.by + * }) * } * }); * @@ -252,86 +280,47 @@ export type TransitionsFromEventPayloadMap< * store.send({ type: 'inc', by: 5 }); * // Logs { context: { count: 5 }, status: 'active', ... } * ``` - */ -export function createStore< - TContext extends StoreContext, - TEventPayloadMap extends EventPayloadMap, - TTypes extends { emitted?: EventObject } ->({ - context, - on, - types -}: { - context: TContext; - on: { - [K in keyof TEventPayloadMap & string]: - | StoreAssigner< - NoInfer, - { type: K } & TEventPayloadMap[K], - Cast - > - | StorePropertyAssigner< - NoInfer, - { type: K } & TEventPayloadMap[K], - Cast - >; - }; -} & { types?: TTypes }): Store< - TContext, - ExtractEventsFromPayloadMap, - Cast ->; - -/** - * Creates a **store** that has its own internal state and can be sent events - * that update its internal state based on transitions. - * - * @example - * - * ```ts - * const store = createStore( - * // Initial context - * { count: 0 }, - * // Transitions - * { - * inc: (context, event: { by: number }) => { - * return { - * count: context.count + event.by - * }; - * } - * } - * ); - * - * store.subscribe((snapshot) => { - * console.log(snapshot); - * }); * - * store.send({ type: 'inc', by: 5 }); - * // Logs { context: { count: 5 }, status: 'active', ... } - * ``` + * @param config - The store configuration object + * @param config.context - The initial state of the store + * @param config.on - An object mapping event types to transition functions + * @returns A store instance with methods to send events and subscribe to state + * changes */ -export function createStore< +function _createStore< TContext extends StoreContext, - TEventPayloadMap extends EventPayloadMap + TEventPayloadMap extends EventPayloadMap, + TEmitted extends EventPayloadMap >( - initialContext: TContext, - transitions: TransitionsFromEventPayloadMap< - TEventPayloadMap, + ...[{ context, on }]: CreateStoreParameterTypes< TContext, - EventObject + TEventPayloadMap, + TEmitted > -): Store, EventObject>; - -export function createStore(initialContextOrObject: any, transitions?: any) { - if (transitions === undefined) { - return createStoreCore( - initialContextOrObject.context, - initialContextOrObject.on - ); - } - return createStoreCore(initialContextOrObject, transitions); +): CreateStoreReturnType { + return createStoreCore(context, on); } +export const createStore: { + // those overloads are exactly the same, we only duplicate them so TypeScript can: + // 1. assign contextual parameter types during inference attempt for the first overload when the source object is still context-sensitive and often non-inferrable + // 2. infer correctly during inference attempt for the second overload when the parameter types are already "known" + < + TContext extends StoreContext, + TEventPayloadMap extends EventPayloadMap, + TEmitted extends EventPayloadMap + >( + ...args: CreateStoreParameterTypes + ): CreateStoreReturnType; + < + TContext extends StoreContext, + TEventPayloadMap extends EventPayloadMap, + TEmitted extends EventPayloadMap + >( + ...args: CreateStoreParameterTypes + ): CreateStoreReturnType; +} = _createStore; + /** * Creates a `Store` with a provided producer (such as Immer's `producer(…)` A * store has its own internal state and can receive events. @@ -342,14 +331,10 @@ export function createStore(initialContextOrObject: any, transitions?: any) { * import { produce } from 'immer'; * * const store = createStoreWithProducer(produce, { - * // Initial context - * { count: 0 }, - * // Transitions - * { - * on: { - * inc: (context, event: { by: number }) => { - * context.count += event.by; - * } + * context: { count: 0 }, + * on: { + * inc: (context, event: { by: number }) => { + * context.count += event.by; * } * } * }); @@ -365,7 +350,7 @@ export function createStore(initialContextOrObject: any, transitions?: any) { export function createStoreWithProducer< TContext extends StoreContext, TEventPayloadMap extends EventPayloadMap, - TEmitted extends EventObject = EventObject + TEmittedPayloadMap extends EventPayloadMap >( producer: NoInfer< (context: TContext, recipe: (context: TContext) => void) => TContext @@ -376,53 +361,16 @@ export function createStoreWithProducer< [K in keyof TEventPayloadMap & string]: ( context: NoInfer, event: { type: K } & TEventPayloadMap[K], - enqueue: EnqueueObject + enqueue: EnqueueObject> ) => void; }; } -): Store, TEmitted>; -export function createStoreWithProducer< - TContext extends StoreContext, - TEventPayloadMap extends EventPayloadMap, - TEmitted extends EventObject = EventObject ->( - producer: NoInfer< - (context: TContext, recipe: (context: TContext) => void) => TContext - >, - initialContext: TContext, - transitions: { - [K in keyof TEventPayloadMap & string]: ( - context: NoInfer, - event: { type: K } & TEventPayloadMap[K], - enqueue: EnqueueObject - ) => void; - } -): Store, TEmitted>; - -export function createStoreWithProducer< - TContext extends StoreContext, - TEventPayloadMap extends EventPayloadMap, - TEmitted extends EventObject = EventObject ->( - producer: ( - context: TContext, - recipe: (context: TContext) => void - ) => TContext, - initialContextOrConfig: any, - transitions?: any -): Store, TEmitted> { - if ( - typeof initialContextOrConfig === 'object' && - 'context' in initialContextOrConfig && - 'on' in initialContextOrConfig - ) { - return createStoreCore( - initialContextOrConfig.context, - initialContextOrConfig.on, - producer - ); - } - return createStoreCore(initialContextOrConfig, transitions, producer); +): Store< + TContext, + ExtractEvents, + ExtractEvents +> { + return createStoreCore(config.context, config.on, producer); } declare global { @@ -432,12 +380,14 @@ declare global { } /** - * Creates a store function, which is a function that accepts the current - * snapshot and an event and returns a new snapshot. + * Creates a store transition function that handles state updates based on + * events. * - * @param transitions - * @param updater - * @returns + * @param transitions - An object mapping event types to transition functions + * @param producer - Optional producer function (e.g., Immer's produce) for + * immutable updates + * @returns A transition function that takes a snapshot and event and returns a + * new snapshot with effects */ export function createStoreTransition< TContext extends StoreContext, @@ -445,44 +395,54 @@ export function createStoreTransition< TEmitted extends EventObject >( transitions: { - [K in keyof TEventPayloadMap & string]: - | StoreAssigner - | StorePropertyAssigner< - TContext, - { type: K } & TEventPayloadMap[K], - TEmitted - >; + [K in keyof TEventPayloadMap & string]: StoreAssigner< + TContext, + { type: K } & TEventPayloadMap[K], + TEmitted + >; }, - updater?: ( + producer?: ( context: TContext, - recipe: (context: TContext) => TContext + recipe: (context: TContext) => void ) => TContext ) { return ( snapshot: StoreSnapshot, - event: ExtractEventsFromPayloadMap - ): [StoreSnapshot, TEmitted[]] => { - type StoreEvent = ExtractEventsFromPayloadMap; + event: ExtractEvents + ): [StoreSnapshot, StoreEffect[]] => { + type StoreEvent = ExtractEvents; let currentContext = snapshot.context; const assigner = transitions?.[event.type as StoreEvent['type']]; - const emitted: TEmitted[] = []; - - const enqueue = { - emit: (ev: TEmitted) => { - emitted.push(ev); + const effects: StoreEffect[] = []; + + const enqueue: EnqueueObject = { + emit: new Proxy({} as any, { + get: (_, eventType: string) => { + return (payload: any) => { + effects.push({ + type: eventType, + ...payload + }); + }; + } + }), + effect: (fn) => { + effects.push(fn); } }; if (!assigner) { - return [snapshot, emitted]; + return [snapshot, effects]; } if (typeof assigner === 'function') { - currentContext = updater - ? updater(currentContext, (draftContext) => - ( - assigner as StoreCompleteAssigner - )?.(draftContext, event, enqueue) + currentContext = producer + ? producer(currentContext, (draftContext) => + (assigner as StoreProducerAssigner)( + draftContext, + event, + enqueue + ) ) : setter(currentContext, (draftContext) => Object.assign( @@ -501,24 +461,25 @@ export function createStoreTransition< const propAssignment = assigner[key]; partialUpdate[key] = typeof propAssignment === 'function' - ? ( - propAssignment as StorePartialAssigner< - TContext, - StoreEvent, - typeof key, - TEmitted - > - )(currentContext, event, enqueue) + ? (propAssignment as StoreAssigner)( + currentContext, + event, + enqueue + ) : propAssignment; } currentContext = Object.assign({}, currentContext, partialUpdate); } - return [{ ...snapshot, context: currentContext }, emitted]; + return [{ ...snapshot, context: currentContext }, effects]; }; } -// create a unique 6-char id +/** + * Generates a unique 6-character identifier. + * + * @returns A random string identifier + */ function uniqueId() { return Math.random().toString(36).slice(6); } diff --git a/packages/xstate-store/src/types.ts b/packages/xstate-store/src/types.ts index 23b90758b8..edff42361e 100644 --- a/packages/xstate-store/src/types.ts +++ b/packages/xstate-store/src/types.ts @@ -1,15 +1,20 @@ export type EventPayloadMap = Record; -export type ExtractEventsFromPayloadMap = Values<{ +export type ExtractEvents = Values<{ [K in keyof T & string]: T[K] & { type: K }; }>; export type Recipe = (state: T) => TReturn; -export type EnqueueObject = { - emit: (ev: TEmitted) => void; +export type EnqueueObject = { + emit: { + [E in TEmittedEvent as E['type']]: (payload: Omit) => void; + }; + effect: (fn: () => void) => void; }; +export type StoreEffect = (() => void) | TEmitted; + export type StoreAssigner< TContext extends StoreContext, TEvent extends EventObject, @@ -18,31 +23,13 @@ export type StoreAssigner< context: TContext, event: TEvent, enq: EnqueueObject -) => Partial; -export type StoreCompleteAssigner< - TContext, - TEvent extends EventObject, - TEmitted extends EventObject -> = (ctx: TContext, ev: TEvent, enq: EnqueueObject) => TContext; -export type StorePartialAssigner< - TContext, - TEvent extends EventObject, - K extends keyof TContext, - TEmitted extends EventObject -> = ( - ctx: TContext, - ev: TEvent, - enq: EnqueueObject -) => Partial[K]; -export type StorePropertyAssigner< - TContext, +) => TContext | void; + +export type StoreProducerAssigner< + TContext extends StoreContext, TEvent extends EventObject, TEmitted extends EventObject -> = { - [K in keyof TContext]?: - | TContext[K] - | StorePartialAssigner; -}; +> = (context: TContext, event: TEvent, enq: EnqueueObject) => void; export type Snapshot = | { @@ -105,8 +92,29 @@ export interface Store< ev: Compute ) => void ) => Subscription; + /** + * A proxy object that allows you to send events to the store without manually + * constructing event objects. + * + * @example + * + * ```ts + * // Equivalent to: + * // store.send({ type: 'increment', by: 1 }); + * store.trigger.increment({ by: 1 }); + * ``` + */ + trigger: { + [E in TEvent as E['type'] & string]: IsEmptyObject< + Omit + > extends true + ? () => Omit + : (eventPayload: Omit) => void; + }; } +export type IsEmptyObject = T extends Record ? true : false; + export type AnyStore = Store; export type Compute = { [K in keyof A]: A[K] }; @@ -300,3 +308,7 @@ export type ActorRefLike = { export type Prop = K extends keyof T ? T[K] : never; export type Cast = A extends B ? A : B; + +export type EventMap = { + [E in TEvent as E['type']]: E; +}; diff --git a/packages/xstate-store/test/UseActor.vue b/packages/xstate-store/test/UseActor.vue index 676cd2e75e..0f16cf76d6 100644 --- a/packages/xstate-store/test/UseActor.vue +++ b/packages/xstate-store/test/UseActor.vue @@ -15,14 +15,14 @@ export default defineComponent({ emits: ['rerender'], setup() { const { snapshot, send } = useActor( - fromStore( - { + fromStore({ + context: { count: 0 }, - { + on: { inc: (ctx) => ({ count: ctx.count + 1 }) } - ) + }) ); snapshot.value.context.count satisfies number; diff --git a/packages/xstate-store/test/UseActorRef.vue b/packages/xstate-store/test/UseActorRef.vue index f8548a0ff6..80db6198b4 100644 --- a/packages/xstate-store/test/UseActorRef.vue +++ b/packages/xstate-store/test/UseActorRef.vue @@ -15,14 +15,14 @@ export default defineComponent({ emits: ['rerender'], setup() { const actorRef = useActorRef( - fromStore( - { + fromStore({ + context: { count: 0 }, - { + on: { inc: (ctx) => ({ count: ctx.count + 1 }) } - ) + }) ); const count = useSelector(actorRef, (s) => s.context.count); diff --git a/packages/xstate-store/test/UseSelector.vue b/packages/xstate-store/test/UseSelector.vue index 9f4c09019f..d38af28c50 100644 --- a/packages/xstate-store/test/UseSelector.vue +++ b/packages/xstate-store/test/UseSelector.vue @@ -14,14 +14,14 @@ import { createStore } from '../src/index.ts'; export default defineComponent({ emits: ['rerender'], setup() { - const store = createStore( - { + const store = createStore({ + context: { count: 0 }, - { + on: { inc: (ctx) => ({ count: ctx.count + 1 }) } - ); + }); const count = useSelector(store, (state) => state.context.count); count satisfies Ref; diff --git a/packages/xstate-store/test/fromStore.test.ts b/packages/xstate-store/test/fromStore.test.ts index 0f6cc1d75d..4b8472219c 100644 --- a/packages/xstate-store/test/fromStore.test.ts +++ b/packages/xstate-store/test/fromStore.test.ts @@ -2,34 +2,15 @@ import { createActor } from 'xstate'; import { fromStore } from '../src/index.ts'; describe('fromStore', () => { - it('creates an actor from store logic with input (2 args)', () => { - const storeLogic = fromStore((count: number) => ({ count }), { - inc: { - count: (ctx, ev: { by: number }) => { - return ctx.count + ev.by; - } - } - }); - - const actor = createActor(storeLogic, { - input: 42 - }); - - actor.start(); - - actor.send({ type: 'inc', by: 8 }); - - expect(actor.getSnapshot().context.count).toEqual(50); - }); - - it('creates an actor from store logic with input (object API)', () => { + it('creates an actor from store logic with input', () => { const storeLogic = fromStore({ context: (count: number) => ({ count }), on: { - inc: { - count: (ctx, ev: { by: number }) => { - return ctx.count + ev.by; - } + inc: (ctx, ev: { by: number }) => { + return { + ...ctx, + count: ctx.count + ev.by + }; } } }); @@ -49,16 +30,17 @@ describe('fromStore', () => { const spy = jest.fn(); const storeLogic = fromStore({ - types: { - emitted: {} as { type: 'increased'; upBy: number } - }, context: (count: number) => ({ count }), + emits: { + increased: (_: { upBy: number }) => {} + }, on: { - inc: { - count: (ctx, ev: { by: number }, enq) => { - enq.emit({ type: 'increased', upBy: ev.by }); - return ctx.count + ev.by; - } + inc: (ctx, ev: { by: number }, enq) => { + enq.emit.increased({ upBy: ev.by }); + return { + ...ctx, + count: ctx.count + ev.by + }; } } }); diff --git a/packages/xstate-store/test/react.test.tsx b/packages/xstate-store/test/react.test.tsx index b05dcba39f..ac8545954d 100644 --- a/packages/xstate-store/test/react.test.tsx +++ b/packages/xstate-store/test/react.test.tsx @@ -9,16 +9,17 @@ import { import ReactDOM from 'react-dom'; it('useSelector should work', () => { - const store = createStore( - { + const store = createStore({ + context: { count: 0 }, - { - inc: { - count: (ctx) => ctx.count + 1 - } + on: { + inc: (ctx) => ({ + ...ctx, + count: ctx.count + 1 + }) } - ); + }); const Counter = () => { const count = useSelector(store, (s) => s.context.count); @@ -47,19 +48,21 @@ it('useSelector should work', () => { }); it('useSelector can take in a custom comparator', () => { - const store = createStore( - { + const store = createStore({ + context: { items: [1, 2] }, - { - same: { - items: () => [1, 2] // different array, same items - }, - different: { - items: () => [3, 4] - } + on: { + same: (ctx) => ({ + ...ctx, + items: [1, 2] // different array, same items + }), + different: (ctx) => ({ + ...ctx, + items: [3, 4] + }) } - ); + }); let renderCount = 0; const Items = () => { @@ -112,16 +115,17 @@ it('useSelector can take in a custom comparator', () => { }); it('can batch updates', () => { - const store = createStore( - { + const store = createStore({ + context: { count: 0 }, - { - inc: { - count: (ctx) => ctx.count + 1 - } + on: { + inc: (ctx) => ({ + ...ctx, + count: ctx.count + 1 + }) } - ); + }); const Counter = () => { const count = useSelector(store, (s) => s.context.count); @@ -153,16 +157,17 @@ it('can batch updates', () => { }); it('useSelector (@xstate/react) should work', () => { - const store = createStore( - { + const store = createStore({ + context: { count: 0 }, - { - inc: { - count: (ctx) => ctx.count + 1 - } + on: { + inc: (ctx) => ({ + ...ctx, + count: ctx.count + 1 + }) } - ); + }); const Counter = () => { const count = useXStateSelector(store, (s) => s.context.count); @@ -191,16 +196,17 @@ it('useSelector (@xstate/react) should work', () => { }); it('useActor (@xstate/react) should work', () => { - const store = fromStore( - { + const store = fromStore({ + context: { count: 0 }, - { - inc: { - count: (ctx) => ctx.count + 1 - } + on: { + inc: (ctx) => ({ + ...ctx, + count: ctx.count + 1 + }) } - ); + }); const Counter = () => { const [snapshot, send] = useActor(store); @@ -229,16 +235,17 @@ it('useActor (@xstate/react) should work', () => { }); it('useActorRef (@xstate/react) should work', () => { - const store = fromStore( - { + const store = fromStore({ + context: { count: 0 }, - { - inc: { - count: (ctx) => ctx.count + 1 - } + on: { + inc: (ctx) => ({ + ...ctx, + count: ctx.count + 1 + }) } - ); + }); const Counter = () => { const actorRef = useActorRef(store); diff --git a/packages/xstate-store/test/solid.test.tsx b/packages/xstate-store/test/solid.test.tsx index f690da0a91..1d8698cfae 100644 --- a/packages/xstate-store/test/solid.test.tsx +++ b/packages/xstate-store/test/solid.test.tsx @@ -19,13 +19,19 @@ const useRenderTracker = (...accessors: Accessor[]) => { /** A commonly reused store for testing selector behaviours. */ const createCounterStore = () => - createStore( - { count: 0, other: 0 }, - { - increment: { count: ({ count }) => count + 1 }, - other: { other: ({ other }) => other + 1 } + createStore({ + context: { count: 0, other: 0 }, + on: { + increment: (ctx) => ({ + ...ctx, + count: ctx.count + 1 + }), + other: (ctx) => ({ + ...ctx, + other: ctx.other + 1 + }) } - ); + }); describe('Solid.js integration', () => { describe('useSelector', () => { @@ -72,13 +78,19 @@ describe('Solid.js integration', () => { const INITIAL_ITEMS_STRING = INITIAL_ITEMS.join(','); const DIFFERENT_ITEMS_STRING = DIFFERENT_ITEMS.join(','); - const store = createStore( - { items: INITIAL_ITEMS }, - { - same: { items: () => [...INITIAL_ITEMS] }, - different: { items: () => DIFFERENT_ITEMS } + const store = createStore({ + context: { items: INITIAL_ITEMS }, + on: { + same: (ctx) => ({ + ...ctx, + items: [...INITIAL_ITEMS] + }), + different: (ctx) => ({ + ...ctx, + items: DIFFERENT_ITEMS + }) } - ); + }); const ItemList: Component<{ itemStore: typeof store; diff --git a/packages/xstate-store/test/store.test.ts b/packages/xstate-store/test/store.test.ts index 2e5db1bd07..2b4add8311 100644 --- a/packages/xstate-store/test/store.test.ts +++ b/packages/xstate-store/test/store.test.ts @@ -4,11 +4,14 @@ import { createBrowserInspector } from '@statelyai/inspect'; it('updates a store with an event without mutating original context', () => { const context = { count: 0 }; - const store = createStore(context, { - inc: (context, event: { by: number }) => { - return { - count: context.count + event.by - }; + const store = createStore({ + context, + on: { + inc: (context, event: { by: number }) => { + return { + count: context.count + event.by + }; + } } }); @@ -23,19 +26,20 @@ it('updates a store with an event without mutating original context', () => { expect(context.count).toEqual(0); }); -it('can update context with a property assigner', () => { - const store = createStore( - { count: 0, greeting: 'hello' }, - { - inc: { - count: (ctx) => ctx.count + 1 - }, - updateBoth: { - count: () => 42, +it('can update context', () => { + const store = createStore({ + context: { count: 0, greeting: 'hello' }, + on: { + inc: (ctx) => ({ + ...ctx, + count: ctx.count + 1 + }), + updateBoth: () => ({ + count: 42, greeting: 'hi' - } + }) } - ); + }); store.send({ type: 'inc' @@ -49,14 +53,14 @@ it('can update context with a property assigner', () => { }); it('handles unknown events (does not do anything)', () => { - const store = createStore( - { count: 0 }, - { - inc: { - count: (ctx) => ctx.count + 1 - } + const store = createStore({ + context: { count: 0 }, + on: { + inc: (ctx) => ({ + count: ctx.count + 1 + }) } - ); + }); store.send({ // @ts-expect-error @@ -66,11 +70,11 @@ it('handles unknown events (does not do anything)', () => { }); it('updates state from sent events', () => { - const store = createStore( - { + const store = createStore({ + context: { count: 0 }, - { + on: { inc: (ctx, ev: { by: number }) => { return { count: ctx.count + ev.by @@ -87,7 +91,7 @@ it('updates state from sent events', () => { }; } } - ); + }); store.send({ type: 'inc', by: 9 }); store.send({ type: 'dec', by: 3 }); @@ -99,17 +103,16 @@ it('updates state from sent events', () => { }); it('createStoreWithProducer(…) works with an immer producer', () => { - const store = createStoreWithProducer( - produce, - { + const store = createStoreWithProducer(produce, { + context: { count: 0 }, - { + on: { inc: (ctx, ev: { by: number }) => { ctx.count += ev.by; } } - ); + }); store.send({ type: 'inc', by: 3 }); store.send({ @@ -144,28 +147,16 @@ it('createStoreWithProducer(…) works with an immer producer (object API)', () }); it('createStoreWithProducer(…) infers the context type properly with a producer', () => { - const store = createStoreWithProducer( - produce, - { - count: 0 - }, - { - inc: (ctx, ev: { by: number }) => { - ctx.count += ev.by; - } - } - ); - - store.getSnapshot().context satisfies { count: number }; -}); - -it('createStoreWithProducer(…) infers the context type properly with a producer (object API)', () => { const store = createStoreWithProducer(produce, { context: { count: 0 }, on: { - inc: (ctx, ev: { by: number }, enq) => { + inc: (ctx, ev: { by: number }) => { + ctx satisfies { count: number }; + // @ts-expect-error + ctx satisfies { count: string }; + ctx.count += ev.by; } } @@ -175,21 +166,23 @@ it('createStoreWithProducer(…) infers the context type properly with a produce }); it('can be observed', () => { - const store = createStore( - { + const store = createStore({ + context: { count: 0 }, - { - inc: { - count: (ctx) => ctx.count + 1 - } + on: { + inc: (ctx) => ({ + count: ctx.count + 1 + }) } - ); + }); const counts: number[] = []; const sub = store.subscribe((s) => counts.push(s.context.count)); + expect(counts).toEqual([]); + store.send({ type: 'inc' }); // 1 store.send({ type: 'inc' }); // 2 store.send({ type: 'inc' }); // 3 @@ -206,16 +199,16 @@ it('can be observed', () => { }); it('can be inspected', () => { - const store = createStore( - { + const store = createStore({ + context: { count: 0 }, - { - inc: { - count: (ctx) => ctx.count + 1 - } + on: { + inc: (ctx) => ({ + count: ctx.count + 1 + }) } - ); + }); const evs: any[] = []; @@ -257,18 +250,15 @@ it('inspection with @statelyai/inspect typechecks correctly', () => { it('emitted events can be subscribed to', () => { const store = createStore({ - types: { - emitted: {} as - | { type: 'increased'; upBy: number } - | { type: 'decreased'; downBy: number } - }, context: { count: 0 }, + emits: { + increased: (a: { upBy: number }) => {} + }, on: { inc: (ctx, _, enq) => { - enq.emit({ type: 'increased', upBy: 1 }); - + enq.emit.increased({ upBy: 1 }); return { ...ctx, count: ctx.count + 1 @@ -288,17 +278,15 @@ it('emitted events can be subscribed to', () => { it('emitted events can be unsubscribed to', () => { const store = createStore({ - types: { - emitted: {} as - | { type: 'increased'; upBy: number } - | { type: 'decreased'; downBy: number } - }, context: { count: 0 }, + emits: { + increased: (_: { upBy: number }) => {} + }, on: { inc: (ctx, _, enq) => { - enq.emit({ type: 'increased', upBy: 1 }); + enq.emit.increased({ upBy: 1 }); return { ...ctx, @@ -322,15 +310,15 @@ it('emitted events can be unsubscribed to', () => { it('emitted events occur after the snapshot is updated', () => { const store = createStore({ - types: { - emitted: {} as { type: 'increased'; upBy: number } - }, context: { count: 0 }, + emits: { + increased: (_: { upBy: number }) => {} + }, on: { inc: (ctx, _, enq) => { - enq.emit({ type: 'increased', upBy: 1 }); + enq.emit.increased({ upBy: 1 }); return { ...ctx, @@ -350,3 +338,146 @@ it('emitted events occur after the snapshot is updated', () => { store.send({ type: 'inc' }); }); + +it('effects can be enqueued', async () => { + const store = createStore({ + context: { + count: 0 + }, + on: { + inc: (ctx, _, enq) => { + enq.effect(() => { + setTimeout(() => { + store.send({ type: 'dec' }); + }, 5); + }); + + return { + ...ctx, + count: ctx.count + 1 + }; + }, + dec: (ctx) => ({ + ...ctx, + count: ctx.count - 1 + }) + } + }); + + store.send({ type: 'inc' }); + + expect(store.getSnapshot().context.count).toEqual(1); + + await new Promise((resolve) => setTimeout(resolve, 10)); + + expect(store.getSnapshot().context.count).toEqual(0); +}); + +describe('store.trigger', () => { + it('should allow triggering events with a fluent API', () => { + const store = createStore({ + context: { count: 0 }, + on: { + increment: (ctx, event: { by: number }) => ({ + count: ctx.count + event.by + }) + } + }); + + store.trigger.increment({ by: 5 }); + + expect(store.getSnapshot().context.count).toBe(5); + }); + + it('should provide type safety for event payloads', () => { + const store = createStore({ + context: { count: 0 }, + on: { + increment: (ctx, event: { by: number }) => ({ + count: ctx.count + event.by + }), + reset: () => ({ count: 0 }) + } + }); + + // @ts-expect-error - missing required 'by' property + store.trigger.increment({}); + + // @ts-expect-error - extra property not allowed + store.trigger.increment({ by: 1, extra: true }); + + // @ts-expect-error - unknown event + store.trigger.unknown({}); + + // Valid usage with no payload + store.trigger.reset(); + + // Valid usage with payload + store.trigger.increment({ by: 1 }); + }); + + it('should be equivalent to store.send', () => { + const store = createStore({ + context: { count: 0 }, + on: { + increment: (ctx, event: { by: number }) => ({ + count: ctx.count + event.by + }) + } + }); + + const sendSpy = jest.spyOn(store, 'send'); + + store.trigger.increment({ by: 5 }); + + expect(sendSpy).toHaveBeenCalledWith({ + type: 'increment', + by: 5 + }); + }); +}); + +it('works with typestates', () => { + type ContextStates = + | { + status: 'loading'; + data: null; + } + | { + status: 'success'; + data: string; + }; + + const store = createStore({ + context: { + status: 'loading', + data: null + } as ContextStates, + on: { + loaded: () => ({ + status: 'success' as const, + data: 'hello' + }), + loading: () => ({ + status: 'loading' as const, + data: null + }) + } + }); + + const context = store.getSnapshot().context; + + if (context.status === 'loading') { + context.data satisfies null; + // @ts-expect-error + context.data satisfies string; + } else { + context.status satisfies 'success'; + // @ts-expect-error + context.status satisfies 'loading'; + + context.data satisfies string; + // @ts-expect-error + context.data satisfies null; + } +}); diff --git a/packages/xstate-store/test/types.test.tsx b/packages/xstate-store/test/types.test.tsx index 2bbf40e24a..a927977249 100644 --- a/packages/xstate-store/test/types.test.tsx +++ b/packages/xstate-store/test/types.test.tsx @@ -3,18 +3,14 @@ import { createStore } from '../src/index'; describe('emitted', () => { it('can emit a known event', () => { createStore({ - types: { - emitted: {} as - | { type: 'increased'; upBy: number } - | { type: 'decreased'; downBy: number } - }, context: {}, + emits: { + increased: (_: { upBy: number }) => {} + }, on: { - inc: { - count: (ctx, _: {}, enq) => { - enq.emit({ type: 'increased', upBy: 1 }); - return ctx; - } + inc: (ctx, _, enq) => { + enq.emit.increased({ upBy: 1 }); + return ctx; } } }); @@ -22,72 +18,50 @@ describe('emitted', () => { it("can't emit an unknown event", () => { createStore({ - types: { - emitted: {} as - | { type: 'increased'; upBy: number } - | { type: 'decreased'; downBy: number } - }, context: {}, - on: { - inc: { - count: (ctx, _: {}, enq) => { - enq.emit({ - // @ts-expect-error - type: 'unknown' - }); - return ctx; - } - } - } - }); - }); - - it("can't emit a known event with wrong payload", () => { - createStore({ - types: { - emitted: {} as - | { type: 'increased'; upBy: number } - | { type: 'decreased'; downBy: number } + emits: { + increased: (_: { upBy: number }) => {}, + decreased: (_: { downBy: number }) => {} }, - context: {}, on: { - inc: { - count: (ctx, _: {}, enq) => { - enq.emit({ - type: 'increased', - // @ts-expect-error - upBy: 'bazinga' - }); - return ctx; - } + inc: (ctx, _, enq) => { + enq.emit + // @ts-expect-error + .unknown(); + return ctx; } } }); }); - it('can emit an event when emitted events are unknown', () => { + it("can't emit a known event with wrong payload", () => { createStore({ context: {}, + emits: { + increased: (_: { upBy: number }) => {}, + decreased: (_: { downBy: number }) => {} + }, on: { - inc: { - count: (ctx, _: {}, enq) => { - enq.emit({ - type: 'unknown' - }); - return ctx; - } + inc: (ctx, _, enq) => { + enq.emit.increased({ + // @ts-expect-error + upBy: 'bazinga' + }); + return ctx; } } }); }); it('can subscribe to a known event', () => { - const store = createStore({ - types: { - emitted: {} as - | { type: 'increased'; upBy: number } - | { type: 'decreased'; downBy: number } - }, + const store = createStore< + {}, + {}, + { + increased: { upBy: number }; + decreased: { downBy: number }; + } + >({ context: {}, on: {} }); @@ -97,17 +71,17 @@ describe('emitted', () => { }); }); - it("can can't subscribe to a unknown event", () => { + it("can't subscribe to a unknown event", () => { const store = createStore({ - types: { - emitted: {} as - | { type: 'increased'; upBy: number } - | { type: 'decreased'; downBy: number } + emits: { + increased: (_: { upBy: number }) => {} }, context: {}, on: {} }); + store.on('increased', (ev) => {}); + store.on( // @ts-expect-error 'unknown',