Major Changes
-
#44
52970eaThanks @pull! - Invoked and spawned actors are no longer started directly byactor.start(). They now start as part of the transition that creates them (via an internal deferred start action), the same way other entry effects run.The user-visible consequence: a child that fails synchronously while starting now surfaces that failure through the invoking state's
onErrortransition instead of throwing out ofactor.start():const machine = createMachine({ initial: 'loading', states: { loading: { invoke: { src: createAsyncLogic({ run: () => { throw new Error('boom'); // sync failure on start } }), onError: 'failed' } }, failed: {} } }); const actor = createActor(machine).start(); // does not throw actor.getSnapshot().value; // 'failed'
Restored (rehydrated) children that were active when a snapshot was persisted are still restarted on
actor.start(), so persistence behavior is unchanged. -
#44
52970eaThanks @pull! - Actions, guards, and transitions are now plain inline functions, and the v5 action/guard creators are removed.Removed exports:
assign,raise,sendTo,sendParent,forwardTo,emit,log,cancel,spawnChild,stop,stopChild,enqueueActions, and the guard creatorsand,or,not,stateIn.Instead, a transition/action/guard is a function
(args, enq) => ...:- Update context by returning a partial-or-full
{ context }patch (no moreassign). - Perform side effects through the
enqenqueue object:enq.raise,enq.sendTo,enq.emit,enq.log,enq.cancel,enq.spawn,enq.stop, plusenq(fn, ...args)for arbitrary effects. - Guards are just functions that return a boolean (or
undefined/falseto block).
- import { assign, raise, sendTo, and, not } from 'xstate'; const machine = createMachine({ context: { count: 0 }, on: { - INC: { - guard: and([not('isMax'), 'isReady']), - actions: assign({ count: ({ context }) => context.count + 1 }) - } + INC: ({ context, guards }) => { + if (guards.isMax(context) || !guards.isReady(context)) return; + return { context: { count: context.count + 1 } }; + } } });
The
stateInguard is replaced by checking the snapshot directly — usesnapshot.matches(...)inside a transition function:on: { CHECK: ({ self }) => { if (self.getSnapshot().matches({ b: 'b2' })) { return { target: 'a2' }; } }; }
For matching by state id (the
'#id'form, whichmatches()doesn't resolve), the exportedcheckStateIn(snapshot, '#id')helper is also available. - Update context by returning a partial-or-full
-
#44
52970eaThanks @pull! - Remove the deprecatedinterpretfunction andInterpretertype. UsecreateActor(...)andActor(orActorRefFrom<...>) instead.- import { interpret, type Interpreter } from 'xstate'; - const actor = interpret(machine); + import { createActor, type Actor } from 'xstate'; + const actor = createActor(machine);
-
#44
52970eaThanks @pull! -schemasis now the way to type a machine, replacing v5'stypes: {} as {...}. Eachschemasfield accepts any Standard Schema (Zod, Valibot, …) for both type inference and (where supported) runtime validation, ortypes<T>()for types only.Notably,
schemas.eventsis a map of event-type → payload schema, inferred into a discriminated union keyed bytype:import { createMachine } from 'xstate'; import { z } from 'zod'; const machine = createMachine({ schemas: { context: z.object({ count: z.number() }), events: { inc: z.object({ by: z.number() }), reset: z.object({}) }, input: z.object({ start: z.number() }), output: z.object({ total: z.number() }), emitted: { changed: z.object({ count: z.number() }) }, tags: z.union([z.literal('busy'), z.literal('idle')]), meta: z.object({ label: z.string() }) }, context: ({ input }) => ({ count: input.start }), initial: 'active', states: { active: { on: { inc: ({ context, event }) => ({ context: { count: context.count + event.by } }) } } } });
context→ context type (literal initial values are widened, so updates typecheck).events→{ type: 'inc'; by: number } | { type: 'reset' }; payloads are typed oneventin every transition/action/guard function.input→ typedcreateActor(machine, { input })and thecontextinitializer argument.output→ typedsnapshot.output.emitted→ typedactor.on('changed', (ev) => ev.count).tags→ constrainssnapshot.hasTag(...).meta→ typed statemeta.
actors,actions,guards, anddelaysare top-level config keys (now inline functions), notschemaskeys. -
#44
96aee67Thanks @pull! - Separate concrete actors from actor refs in public types.ActorRefnow represents the consumer-facing contract for sending events, reading published snapshots, and listening to emitted events withactorRef.on(...); concreteActorinstances provide lifecycle and runtime capabilities and still satisfy actor ref contracts. -
#44
52970eaThanks @pull! -setup(...)no longer registers implementations. It now takes only{ schemas?, states? }and returns{ createMachine, createStateConfig, states }.In v5,
setup({ schemas, actors, actions, guards, delays })registered named implementations and returned action creators (assign,sendTo,raise, …). In v6, actions/guards/actors/delays are plain inline functions, sosetupno longer accepts or returns them. Its job is now machine- and state-level typing: it validates state keys,initial, and transitiontargets against the declaredstates, and types per-stateinput/context.const { createMachine, createStateConfig } = setup({ schemas: { context: types<{ count: number }>(), events: { INC: types<{ value: number }>() } }, states: { idle: {}, loading: { schemas: { input: z.object({ userId: z.string() }) } } } });
setup().createMachine()merges setupschemaswith configschemas. BarecreateMachine({ schemas })infers the same machine-level types without the state-key checks.
Minor Changes
-
#44
52970eaThanks @pull! - Addactor.trigger— a typed event-sender proxy.actor.trigger.EVENT(payload)is shorthand foractor.send({ type: 'EVENT', ...payload }):actor.trigger.NEXT(); actor.trigger.INC({ by: 5 });
-
#44
021cc56Thanks @pull! - Machine JSON revival now preserves more of the serialized machine definition, including delayed transitions, state timeouts, state tags, state output, invoke input, invoke completion transitions, invoke timeouts, and implementation maps passed tocreateMachineFromConfig.const machine = createMachineFromConfig( { initial: 'loading', states: { loading: { invoke: { src: 'loadUser', input: { userId: '42' }, onDone: { target: 'done' }, timeout: 5000, onTimeout: { target: 'timedOut' } } }, done: {}, timedOut: {} } }, { actors: { loadUser } } );
The migration codemod now reports manual review notes for known non-rename migrations such as
fromPromise(...),return assign(...), object-form actions/guards, and legacytypes: {}schema declarations. -
#44
52970eaThanks @pull! -createLogicandcreateAsyncLogicgain a durable-effect enqueue API on theirrunfunction's second argument (enq).enq.effect(key?, fn)registers a side effect that runs once per key (an unnamed effect runs every transition) and is cleaned up when the actor stops.enq.step(key, asyncFn)(async logic) is anawait-able step whose result is memoized into the persisted snapshot undersnapshot.effects[key]. A rehydrated actor replaysrunbut skips steps that already completed, so long-running async logic is resumable across persistence.
const logic = createAsyncLogic({ run: async (_, enq) => { const user = await enq.step('fetchUser', () => fetchUser()); const order = await enq.step('createOrder', () => createOrder(user.id)); return order.id; } }); // snapshot.effects.fetchUser === { status: 'done', output: { id: 1 } }
A pending step can also be resolved externally by sending
{ type: 'xstate.logic.effect.resolve', key, output }. TheLogicEnqueue,LogicEffect, andLogicEffectStatetypes are exported. -
#44
52970eaThanks @pull! - Add timeouts and duration-string delays.-
State-level
timeout/onTimeout— declare a timeout on a state that transitions when the duration elapses (and is cancelled if the state is exited first):states: { waiting: { timeout: 1000, onTimeout: 'escalated' }, escalated: {} }
-
createAsyncLogictimeout— async logic can time out; when it does, the run'sAbortSignalis aborted and the actor errors with aTimeoutError(exported fromxstate):const logic = createAsyncLogic({ timeout: '10ms', run: ({ signal }) => fetch('/slow', { signal }) });
-
Invoke-level
timeout/onTimeout— an invocation can race a timeout: if the invoked actor doesn't complete in time, theonTimeouttransition is taken; if it settles first (or the state is exited), the timeout is cancelled.timeoutaccepts a number, a duration string, a referenced delay, or a function({ context, event }) => duration. Both state- and invoke-leveltimeoutthrow at construction if declared without a matchingonTimeout.working: { invoke: { src: fetchReport, timeout: ({ context }) => context.slaMs, onTimeout: 'timedOut', onDone: 'done' } }
-
Duration-string delays — delays (including
afterandtimeout) accept human-readable strings like'10ms'and'5s', as well as ISO-8601 durations like'PT2M', in addition to numbers:waiting: { after: { '5s': 'timedOut' } }
-
-
#44
d9079cdThanks @pull! - Logic creators now accept Standard Schemas for type inference.createLogic(...)andcreateAsyncLogic(...)acceptschemas.inputand
schemas.output:const loadUser = createAsyncLogic({ schemas: { input: z.object({ userId: z.string() }), output: z.object({ name: z.string() }) }, run: async ({ input }) => { input.userId; // string return { name: 'David' }; } });
The schemas are type-only for now. Runtime validation will be added later as an opt-in behavior.
createCallbackLogic(...),createObservableLogic(...), andcreateEventObservableLogic(...)also acceptschemas.inputwith object-form config:const logic = createCallbackLogic({ schemas: { input: z.object({ userId: z.string() }) }, run: ({ input }) => { input.userId; // string } });
-
#44
52970eaThanks @pull! - AddcreateStateConfig(...)— author a standalone, fully-typed state node config (withschemas) that can be composed into a machine, mirroring howsetup(...).createMachine(...)infers types.import { createStateConfig } from 'xstate'; const loading = createStateConfig({ on: { RESOLVE: 'success' } });
This is the building block for authoring machines as plain data: a
createStateConfignode is a typed, serializable config object you compose into a machine — useful for data-first / JSON-driven state machines (round-tripping withserializeMachine/createMachineFromConfig) while keeping per-state schema typing. -
#44
52970eaThanks @pull! - Addenq.listenandenq.subscribeTofor subscribing to other actors from inside transition/action functions.enq.listen(ref, eventType, mapper)subscribes to events emitted by another actor (supports wildcards like'data.*') and relays a mapped event back to the current actor.enq.subscribeTo(ref, mappers)subscribes to another actor's snapshot/done/error(pass{ snapshot, done, error }, or a single function as snapshot shorthand). It also accepts an atom, in which case the mapper receives the atom's current value.
Both return a stoppable child ref (
enq.stop(ref)) and are torn down automatically when the parent stops. The underlying logic creatorscreateListenerLogicandcreateSubscriptionLogicare exported.entry: (_, enq) => { const child = enq.spawn(childLogic, { id: 'child' }); enq.listen(child, 'data.*', (ev) => ({ type: 'DATA', value: ev.value })); enq.subscribeTo(child, { done: (output) => ({ type: 'CHILD_DONE', output }) }); };
-
#44
52970eaThanks @pull! - AddinternalEvents: a list of event types that may be raised from within the machine (e.g. viaenq.raise(...)) but are rejected when sent to the actor from the outside.const machine = createMachine({ internalEvents: ['tick'] as const, initial: 'idle', states: { idle: { on: { start: (_, enq) => { enq.raise({ type: 'tick' }); // allowed internally }, tick: 'running' } }, running: {} } }); // actor.send({ type: 'tick' }) from outside is rejected
-
#44
021cc56Thanks @pull! - State-levelschemas.contextnow narrows context types for state actions, transition functions, and snapshots checked withsnapshot.matches(...).const machine = setup({ states: { idle: { schemas: { context: z.object({ user: z.null() }) } }, success: { schemas: { context: z.object({ user: z.string() }) } } } }).createMachine({ schemas: { context: z.object({ user: z.string().nullable() }), events: { LOAD: z.object({}) } }, initial: 'idle', context: { user: null }, states: { idle: { on: { LOAD: () => ({ target: 'success', context: { user: 'Ada' } }) } }, success: { entry: ({ context }) => { context.user; // string } } } }); const actor = createActor(machine).start(); const snapshot = actor.getSnapshot(); if (snapshot.matches('success')) { snapshot.context.user; // string }
State-level
schemas.inputis also supported: input supplied on a transition orinitial({ target, input }) is typed in that state's entry/exit and transition functions via({ input }), read from a snapshot withsnapshot.getInputs()(keyed by state node id), and typed recursively for nested states. -
#44
52970eaThanks @pull! - Addchoicestates — a state that immediately routes to a target via a resolver function, returning the first matching transition config.const machine = createMachine({ context: { userStatus: 'vip' }, initial: 'routing', states: { routing: { type: 'choice', choice: ({ context }) => { if (context.userStatus === 'vip') return { target: 'vipFlow' }; return { target: 'standardFlow' }; } }, vipFlow: {}, standardFlow: {} } });
A choice state must declare a
choicefunction and must resolve to a target, and may not declareentry/exit/on/after/invoke— these throw at construction.