Releases: statelyai/xstate
Release list
[email protected]
Patch Changes
-
#5589
e913eebThanks @spokodev! - Fixed a bug where targeting a history state that is a direct child of aparallelstate would silently do nothing when that parallel state had not been visited yet and the history state had no default target. The machine now enters the parallel state's initial configuration, matching the behavior of history states inside compound states.const machine = createMachine({ initial: 'off', states: { off: { on: { GO: 'on.hist' } }, on: { type: 'parallel', states: { regA: { initial: 'a1', states: { a1: {}, a2: {} } }, regB: { initial: 'b1', states: { b1: {}, b2: {} } }, hist: { type: 'history', history: 'deep' } } } } }); const actor = createActor(machine).start(); actor.send({ type: 'GO' }); actor.getSnapshot().value; // { on: { regA: 'a1', regB: 'b1' } }
[email protected]
Minor Changes
-
e410f24: Add state-level
onErrortransitions for handlingxstate.error.*events.State
onErrorcatches actor, execution, and communication errors while the state is active. The caught error is available onevent.error.const machine = createMachine({ initial: 'active', states: { active: { onError: ({ event }) => ({ target: 'failed', context: { message: event.error instanceof Error ? event.error.message : String(event.error) } }) }, failed: {} } });
Patch Changes
-
f6edec7: Allow machines with no external events to be used anywhere
AnyActorLogicorAnyStateMachineis expected.const machine = setup({ schemas: { events: {} } }).createMachine({}); const logic: AnyActorLogic = machine; const anyMachine: AnyStateMachine = machine;
Machines with empty event schemas still reject external events sent to their actors.
@xstate/[email protected]
Patch Changes
-
0f0d6e2: Fixed
fromStoreactor logic so that emitted events and enqueued effects run again. Previously,enq.emit(...)andenq.effect(...)inside a transition were silently dropped.const storeLogic = fromStore({ context: (count: number) => ({ count }), schemas: { emitted: { increased: z.object({ upBy: z.number() }) } }, on: { inc: (ctx, ev: { by: number }, enq) => { enq.emit.increased({ upBy: ev.by }); // now emitted return { ...ctx, count: ctx.count + ev.by }; } } }); const actor = createActor(storeLogic, { input: 42 }); actor.on('increased', (e) => console.log(e.upBy)); actor.start(); actor.send({ type: 'inc', by: 8 }); // logs: 8
[email protected]
Patch Changes
-
#5575
830db8bThanks @JSap0914! - FixedinitialTransition(andtransition) throwing"Actor with system ID '...' already exists"when the machine contains aninvokewith asystemId.Root cause:
createInertActorScopeusedcreateActor(logic)internally, which eagerly rangetInitialSnapshotduring construction and registered anysystemId-carrying child actors in the system. When the caller then rangetInitialSnapshot(ortransition) via the returned scope, the same system was reused, causing the duplicate-registration error.Fix: After creating the internal actor,
createInertActorScopenow replaces the actor's system reference with a freshly-created system. Child actors spawned by the subsequent caller-drivengetInitialSnapshot/transitioninvocation therefore register into a clean system with no pre-existing entries.const machine = createMachine({ initial: 'idle', states: { idle: { invoke: { src: fromPromise(async () => 42), systemId: 'myActor' // previously caused: "Actor with system ID 'myActor' already exists" } } } }); // Now works correctly — returns [snapshot, actions] without throwing const [snapshot, actions] = initialTransition(machine);
-
#5585
a551a2bThanks @RubenFricke! - Add missing https:// protocol to the Stately Studio link in the README
[email protected]
Patch Changes
-
37d3254: Setup-bound invoke transition callbacks now validate target state context requirements for
onDone,onError,onSnapshot, andonTimeout.onDonealso infers output from the invoked actor logic.import { createAsyncLogic, setup } from 'xstate'; import { z } from 'zod'; const machine = setup({ actorSources: { loadUser: createAsyncLogic({ run: async () => ({ name: 'Ada' }) }) }, states: { loading: {}, success: { schemas: { context: z.object({ user: z.object({ name: z.string() }) }) } } } }).createMachine({ context: {}, initial: 'loading', states: { loading: { invoke: { src: 'loadUser', // Type-safe return value for invoke callbacks onDone: ({ event }) => ({ target: 'success', context: { user: event.output } }) } }, success: {} } });
[email protected]
Minor Changes
-
c0c21d0: Add a path-bound overload to
setup(...).createStateConfig(path, config).When a state declares its own input schema, the anonymous
createStateConfig(config)form typesinputas a broad union across all states. This makes the resulting config incompatible with the specific state it's meant for — assigning it insidecreateMachineproduces a type error because the input types don't match.The new
createStateConfig(path, config)overload binds the config to a specific setup-declared state by dotted path (e.g.'loading'or'parent.child'). The addressed state's own input schema is used insideentry/exitargs, and bare transition targets are validated against the state's siblings.const s = setup({ states: { idle: {}, active: { schemas: { input: z.object({ userId: z.string() }) } } } }); // Before: anonymous form — `input` is typed broadly, and assigning this // config to the `active` state in createMachine fails with a type error. const active = s.createStateConfig({ entry: ({ input }) => { // input is not narrowed to { userId: string } } }); // After: path-bound form — `input` is narrowed to `active`'s own schema. const active = s.createStateConfig('active', { entry: ({ input }) => { input.userId; // string } }); // Works for nested states too: const child = s.createStateConfig('parent.child', { ... });
Patch Changes
-
8e3cce6: Fix
snapshot.matches(...)narrowing so repeated checks likesnapshot.matches('loaded') || snapshot.matches('failed')compile correctly, and makeStateFrom<typeof machine>preserve the machine's concrete state value. -
0c2a6e5: Fix function-syntax transitions not passing
inputto target state entry actions.on: { FETCH: ({ context, event }) => ({ target: 'fetching', input: { url: event.url, token: context.authToken } }); }
Previously,
inputreturned from function-syntax transitions was silently ignored. Now it is correctly forwarded to the target state'sentryaction.
[email protected]
Minor Changes
-
bdc54dd: Added
createFSM(...)for flat, actor-compatible finite state machines.import { createActor, createFSM } from 'xstate'; const toggleLogic = createFSM({ initial: 'inactive', context: { count: 0 }, states: { inactive: { on: { toggle: { target: 'active', context: { count: 1 } } } }, active: { on: { toggle: ({ context }, enq) => { enq(() => console.log('toggled')); return { target: 'inactive', context: { count: context.count + 1 } }; } } } } }); const actor = createActor(toggleLogic).start(); actor.send({ type: 'toggle' });
createFSM(...)supports XState-style object transitions, function transitions,enqactions, initial input, stateinput, entry actions, and exit actions. Plain string targets are intentionally not supported; use object targets such as{ target: 'active' }.Simple FSM transitions preserve immutable public snapshots while using structural sharing and a lighter transition path for common
{ target },{ context }, and{ target, context }transitions.
Patch Changes
-
e297115: Object transition configs now support dynamic context patches with a
contextmapper.onDone: { target: 'done', context: ({ context, output }) => ({ answer: output, memory: [...context.memory, output] }) }
@xstate/[email protected]
Patch Changes
-
#5563
2911278Thanks @davidkpiano! - Fixed inference for selectorcontextparameters when usingcreateStoreLogic(...)with an input function.const counterLogic = createStoreLogic({ context: (input: { initialCount: number }) => ({ count: input.initialCount }), selectors: { count: (context) => context.count }, on: { inc: (context) => ({ count: context.count + 1 }) } });
[email protected]
Patch Changes
-
4d9ba1c: Spawning a child with
enq.spawn(...)from a transition function now creates and starts the child actor exactly once for the committed transition.const machine = createMachine({ on: { spawn: (_, enq) => { enq.spawn(childMachine, { registryKey: 'child' }); } } });
-
6798cb1:
serializeMachine(...)andcreateMachineFromConfig(...)now represent inline functions (guards, actions, transitions, delays, route functions) as{ '@code': string, '@lang': 'ts' }. Non-portable values such as actor logic, runtime schemas, class instances, symbols, and bigints are omitted from the serialized JSON.import { serializeMachine } from 'xstate'; serializeMachine(machine); // inline functions → { '@code': '() => true', '@lang': 'ts' }
Type-only refinements:
voidandundefinedare accepted as type-only schemas, async logic output is inferred from an input-only schema, actions/guards can be typed viaschemas, andtriggeris correctly typed on spawned actors.
[email protected]
Patch Changes
-
57e8d85: Machines that declare
schemas.outputnow type-check top-level final state
outputvalues against the machine output type.createMachine({ schemas: { output: types<{ status: 'ok' }>() }, initial: 'done', states: { done: { type: 'final', output: { status: 'ok' } } }, output: ({ event }) => event.output });