Migrate from XState v5 to v6
Every API change from v5 to v6, organized by area, with before-and-after examples.
XState v6 is in alpha
XState v6 is currently in alpha (npm install xstate@alpha). It is a major release that simplifies the authoring experience and unifies actions, guards, and transitions under a single inline function model. Most v5 concepts still exist - they are expressed differently.
This guide is organized by area. Skim the Quick reference below, then jump to the sections relevant to your codebase. Upgrade one machine at a time and run its tests after each change.
Quick reference
| v5 | v6 |
|---|---|
assign({ count: 1 }) | inline fn returning a shallow { context: { count: 1 } } patch |
raise({ type: 'NEXT' }) | enq.raise({ type: 'NEXT' }) |
sendTo(ref, ev) / sendParent(ev) / forwardTo(ref) | enq.sendTo(ref, ev) |
emit({ type: 'x' }) | enq.emit({ type: 'x' }) |
log(...) | enq.log(...) |
cancel(id) | enq.cancel(id) |
spawnChild(logic, opts) | enq.spawn(logic, opts) |
stopChild(idOrRef) | enq.stop(ref) |
enqueueActions(({ enqueue }) => {...}) | regular inline (args, enq) => { ... } function |
and([...]), or([...]), not(...) | plain JS &&, ||, ! inside the guard/inline fn |
stateIn('foo') | checkStateIn(self.getSnapshot(), 'foo') |
interpret(machine), Interpreter | createActor(machine) (already in v5; legacy alias gone) |
fromPromise(async ({ input }) => ...) | createAsyncLogic({ run: async ({ input }, enq) => ... }) |
fromCallback(cb) | createCallbackLogic(cb) (same signature, renamed) |
fromObservable(fn) | createObservableLogic(fn) (same signature, renamed) |
fromEventObservable(fn) | createEventObservableLogic(fn) (same signature, renamed) |
fromTransition(reducer, initial) | createLogic({ context: initial, run: ({ context, event }) => ({ context: reducer(context, event) }) }) |
types: {} as { context: ..., events: ...} | schemas: { context, events, ... } (Zod / Standard Schema, or types<T>() for type-only) |
actor.send({ type: 'INC' }) | actor.send(...) keeps working; new typed actor.trigger.INC() |
@xstate/immer | removed - return updated context patches directly |
@xstate/inspect | removed - use inspect option on createActor, actor.subscribe, or @statelyai/inspect |
What's new in v6
Beyond simplifying the action/guard surface, v6 introduces a number of features with no v5 equivalent. Each links to its detailed section below.
| Feature | What it gives you |
|---|---|
| Inline function transitions | Transitions and actions are plain functions; enq queues side effects. |
| Standard Schema definitions | schemas.context/events/input/output/emitted/meta/tags accept Zod (or any Standard Schema) for TypeScript inference and runtime-readable metadata. |
| State input | State nodes may declare a typed input payload that callers must provide on transition. |
actor.trigger.X() | Type-safe event dispatcher generated from schemas.events - no event-object boilerplate. |
createAsyncLogic | fromPromise rebuilt with id, timeout, AbortSignal, durable enq.step(), and event emission. |
createLogic | New stateful actor logic creator - like a small state machine defined as a single transition function with enq. |
enq.listen / enq.subscribeTo | Declaratively wire child-actor emitted events or snapshot streams back to the parent. |
| Internal events | internalEvents: ['tick', 'change.*'] - events that can be raised inside the machine but rejected when sent from outside. |
| Choice states | First-class type: 'choice' for declarative branch routing (replaces transient always chains). |
| State timeouts | timeout + onTimeout per state - independent of after; auto-cancelled on exit. |
| Duration strings | '250ms', '5s' / '1.5s', and ISO 8601 ('PT1M30S', 'P1DT12H') accepted by state timeouts and async-logic timeouts. |
actor.select | Derive a subscribable, memoized selection from an actor's snapshot - actor.select(s => s.context.x). |
| Route states | A state with route can be navigated to directly via actor.send({ type: 'xstate.route', to: '#id' }), gated by an inline route guard/resolver. |
| Actor registry | actor.system.get(registryKey) / system.get(registryKey) look up actors by registryKey without passing refs. The root actor can be named via system.createActor(machine, { registryKey }). |
| Snapshot versioning | version on the machine is stamped onto persisted snapshots and checked on restore. |
| Serialization | serializeMachine / machineConfigToJSON / createMachineFromConfig are now public - round-trip a machine to/from a plain JSON config. |
createMachineFromConfig | Build a machine from a plain JSON config with serialized actions - useful for SCXML round-trip, persistence, or storing machines as data. |
initial: { target, input } | Object form for initial lets you provide state input on initialization. |
1. Inline functions replace action creators
This is the largest change. Action creators (assign, raise, sendTo, emit, log, cancel, enqueueActions, spawnChild, stopChild, forwardTo, sendParent) are no longer exported.
In v6, every entry, exit, and transition handler is a single function that receives (args, enq) where enq is a queue of side effects. The function can also return an object that the engine applies after it runs:
- Transition handlers (
on,always,after,onTimeout,onDone,onError) - may return a target, a newcontext,reenter, ormeta. - Entry / exit actions - may return a new
context(orchildren). They cannot return atarget; entry/exit cannot transition.
Returned context values are shallow patches. Omitted top-level keys are preserved when the current context is compatible with the next state. If a transition targets a state with narrower schemas.context, include the keys needed to satisfy that target state's context.
Entry / exit actions
// v5
import { createMachine, assign } from 'xstate';
const machine = createMachine({
context: { count: 0 },
entry: assign({ count: 1 }),
exit: () => console.log('bye')
});// v6
import { createMachine } from 'xstate';
const machine = createMachine({
context: { count: 0 },
entry: () => ({ context: { count: 1 } }),
exit: (_, enq) => {
enq(() => console.log('bye'));
}
});Inline-function arguments
The first argument is an object. The keys differ slightly between transition handlers (on, always, after, onTimeout, onDone, onError) and entry/exit actions:
| Key | Transition handler | Entry/exit action | Description |
|---|---|---|---|
context | ✓ | ✓ | Current context |
event | ✓ | ✓ | The event that triggered this transition / state entry / state exit |
self | ✓ | ✓ | This actor's ActorRef - call self.getSnapshot() for the snapshot |
parent | ✓ | ✓ | Parent actor's ActorRef, or undefined for the root |
children | ✓ | ✓ | Record of currently-spawned/invoked child refs |
actions | ✓ | ✓ | Named-action map from createMachine/provide (for referencing) |
actors | ✓ | ✓ | Named actor source map |
guards | ✓ | ✓ | Named-guard map |
delays | ✓ | ✓ | Named-delay map |
value | ✓ | - | Current StateValue |
system | ✓ | ✓ | The actor system |
params | - | ✓ | Parameterized-action params (when invoked as { type, params }) |
Transitions
// v5
on: {
INC: {
actions: assign(({ context }) => ({ count: context.count + 1 }))
},
TOGGLE: {
target: 'inactive',
actions: () => console.log('toggling')
}
}// v6
on: {
INC: ({ context }) => ({
context: { count: context.count + 1 }
}),
TOGGLE: (_, enq) => {
enq(() => console.log('toggling'));
return { target: 'inactive' };
}
}Internal lifecycle events
Generated actor, state-completion, delayed, and timeout events now use stable category types. Identity moved from the event type into payload fields:
| Event type | Identity fields |
|---|---|
xstate.done.actor | actorId, sessionId |
xstate.error.actor | actorId, sessionId |
xstate.done.state | stateId |
xstate.after | stateId, delay |
xstate.timeout | stateId |
xstate.timeout.actor | actorId, optional sessionId |
Use matches to select a specific payload without encoding identity in the
event key:
on: {
'xstate.done.actor': {
matches: { actorId: 'job' },
target: 'complete'
}
}The enq enqueuer
The second argument is the action queue. It buffers side effects so the transition function stays pure.
| Method | Purpose |
|---|---|
enq(fn, ...args) | Enqueue a plain side-effect function (replaces ad-hoc inline () => ... actions) |
enq.raise(event, opts?) | Raise an internal event (opts.delay, opts.id) |
enq.cancel(id) | Cancel a previously raised/sent event by its id (replaces v5 cancel) |
enq.emit(event) | Emit an event observable via actor.on(...) |
enq.log(...args) | Log via the configured logger (replaces v5 log) |
enq.sendTo(ref, event, opts?) | Send an event to another actor (replaces v5 sendTo / sendParent / forwardTo) |
enq.spawn(logic, opts?) | Spawn a child actor; opts.registryKey registers it in a typed system registry |
enq.stop(ref?) | Stop a spawned child or listener (replaces v5 stopChild) |
enq.listen(ref, type, mapper) | Subscribe to a child's emitted events; remap → parent (returns a stoppable ref) |
enq.subscribeTo(ref, mappers) | Subscribe to a child's snapshot stream (returns a stoppable ref) |
// v6
on: {
CLICK: (_, enq) => {
enq.raise({ type: 'TICK' });
enq.emit({ type: 'clicked' });
enq.raise({ type: 'LATE' }, { delay: 500, id: 'lateId' });
};
}What inline functions return
A transition handler may return:
| Field | Meaning |
|---|---|
target | next state (string or string[]) |
context | shallow context patch (treat as immutable - return a new object) |
reenter | force re-entry even if target resolves to the current state |
meta | per-transition meta info |
An entry / exit action may return:
| Field | Meaning |
|---|---|
context | shallow context patch |
children | replacement children record |
Returning nothing (undefined) means "no changes". For transition handlers specifically, returning nothing also means the event is treated as unhandled at this state - useful for inline guarding:
// v6 - guard inline by returning undefined
on: {
toggle: ({ context }) => {
if (context.count > 0) return { target: 'inactive' };
// no return ⇒ event unhandled, machine stays in current state
};
}enqueueActions is gone
enqueueActions(({ enqueue }) => ...) was a way to mix imperative side effects with conditional logic. v6 absorbs this into the regular inline function:
// v5
actions: enqueueActions(({ context, enqueue }) => {
if (context.x) {
enqueue.assign({ y: 1 });
enqueue.raise({ type: 'GO' });
}
});// v6
on: {
EV: ({ context }, enq) => {
if (context.x) {
enq.raise({ type: 'GO' });
return { context: { y: 1 } };
}
};
}2. Guards
The combinators and, or, not, plus stateIn, and the types GuardPredicate and GuardArgs, are no longer exported.
// v5
import { and, not, stateIn } from 'xstate';
on: {
EV: {
target: 'next',
guard: and([
({ context }) => context.isAdmin,
not(stateIn('blocked'))
])
}
}// v6
import { checkStateIn } from 'xstate';
on: {
EV: ({ context, self }) => {
if (context.isAdmin && !checkStateIn(self.getSnapshot(), 'blocked')) {
return { target: 'next' };
}
};
}checkStateIn(snapshot, stateValue) accepts an AnyMachineSnapshot plus either a state-id string (e.g. '#blocked'), a state-path string (e.g. 'parent.child'), or a nested state-value object.
Named guards on setup or createMachine are available as typed functions
in transition (and choice) function args:
// v6
choice: ({ context, guards }) => {
if (guards.isVip(context)) {
return { target: 'vipFlow' };
}
return { target: 'defaultFlow' };
},
guards: {
isVip: ({ isVip }) => isVip
}3. createMachine: schemas replace types
The types: {} as { context: ..., events: ... } shim is replaced by Standard Schema-compatible runtime schemas. Zod is the canonical choice.
If you want types without a runtime schema library (the closest equivalent to v5's type-only types), use types<T>():
import { createMachine, types } from 'xstate';
createMachine({
schemas: {
context: types<{ count: number }>(),
events: { inc: types<{ by: number }>() }
},
context: { count: 0 }
// ...
});// v5
const m = createMachine({
types: {} as {
context: { count: number };
events: { type: 'INC'; by: number };
},
context: { count: 0 },
on: {
INC: {
actions: assign(({ context, event }) => ({
count: context.count + event.by
}))
}
}
});// v6
import { z } from 'zod';
const m = createMachine({
schemas: {
context: z.object({ count: z.number() }),
events: {
INC: z.object({ by: z.number() })
}
},
context: { count: 0 },
on: {
INC: ({ context, event }) => ({
context: { count: context.count + event.by }
})
}
});Full schema surface
schemas: {
context: ZodSchema,
events: { [eventType: string]: ZodSchema }, // map, not union
actions: { [actionType: string]: { params: ZodSchema } },
guards: { [guardType: string]: { params: ZodSchema } },
emitted: { [eventType: string]: ZodSchema },
input: ZodSchema, // machine input
output: ZodSchema, // machine output
meta: ZodSchema, // per-state meta
tags: z.enum([...]), // tag values
children: { [childId: string]: ZodSchema } // invoked/spawned child schemas
}events and emitted are now maps keyed by event type, not unions. Each value is the schema for the event payload (excluding type).
Note:
schemasdrive TypeScript inference only. Runtime validation of context/events/input against the schemas is not performed in this release - do not rely onschemasto reject malformed events at runtime (useinternalEventsfor inbound-event restriction, or validate at your boundary).
Machine output
snapshot.output is set when a machine completes. You can define output on the
root machine, on top-level final states, or both:
createMachine({
schemas: {
output: z.object({ status: z.literal('ok') })
},
initial: 'working',
states: {
working: {
on: { done: { target: 'success' } }
},
success: {
type: 'final',
output: { status: 'ok' }
}
}
});If a top-level final state has output and the root machine does not, the final
state output becomes snapshot.output. If both are present, the final state
output is resolved first and passed to the root output mapper as output.
The root mapper result becomes snapshot.output.
For a parallel root, all regions must complete before the machine is done.
Because there is no single reached top-level final state, define root output
to produce machine output. The root mapper receives the aggregate region output
as output.
Machine input
In v5, machine input was typed via types: {} as { input: ... }. In v6 it moves to schemas.input:
// v5
createMachine({
types: {} as { input: { id: string } },
context: ({ input }) => ({ id: input.id })
});
// v6
createMachine({
schemas: {
input: z.object({ id: z.string() })
},
context: ({ input }) => ({ id: input.id })
});Inferred context (no schema)
If you omit schemas.context, the context type is inferred from the literal context value or from the ({ input }) => ... factory.
4. setup() and providing sources
setup() still exists in v6 and still accepts actions, guards, actors, and delays (merged into every machine created from it). What changed: types is replaced by schemas, and setup() gains a states key for declaring state-level input schemas so createMachine and createStateConfig are typed for the initial: { target, input } form and for transitions targeting those states.
// v6 - setup with root schemas and state-level input schemas
import { setup } from 'xstate';
import { z } from 'zod';
const s = setup({
schemas: {
events: {
LOAD: z.object({})
}
},
states: {
loading: {
schemas: { input: z.object({ userId: z.string() }) }
}
}
});
const loading = s.createStateConfig({});
const machine = s.createMachine({
initial: { target: 'loading', input: { userId: 'u1' } },
states: { loading },
on: {
LOAD: '.loading'
}
});actions, guards, actors, and delays may be declared on setup() or directly on the createMachine config:
// v6
import { createMachine, createAsyncLogic } from 'xstate';
const machine = createMachine({
context: { ready: false },
actions: {
log: (params: { msg: string }) => console.log(params.msg)
},
guards: {
isReady: ({ context }) => context.ready === true
},
actors: {
fetchUser: createAsyncLogic({ run: ({ input }) => fetch(`/u/${input.id}`) })
},
delays: {
short: 250
}
// ... initial / states
});Or attached after the fact via machine.provide({ actions, guards, actors }):
const provided = machine.provide({
actions: { log: (_, p) => myLogger(p) }
});provide() is typed to accept actions, guards, actors, and delays.
5. State input
New in v6, with no v5 equivalent. Each state node can declare an input schema in setup(). Transitions targeting that state pass input alongside target; the target state's entry/exit actions read it from args. Unrelated to v5's params (which existed only on parameterized action/guard objects, not state nodes - that mechanism remains in v6 unchanged for parameterized actions).
// v6
import { setup, createActor } from 'xstate';
import { z } from 'zod';
const s = setup({
states: {
loading: {
schemas: { input: z.object({ userId: z.string() }) }
}
}
});
const machine = s.createMachine({
initial: 'idle',
states: {
idle: {
on: {
LOAD: {
target: 'loading',
input: { userId: 'u1' } // typed against schemas.input
}
}
},
loading: {
entry: ({ input }) => {
console.log(input.userId); // input typed as { userId: string }
}
}
}
});The initial field accepts an object form { target, input? } so the initial state can also receive input. Plain string initial: 'idle' is still supported when no input is needed:
s.createMachine({
initial: { target: 'loading', input: { userId: 'u1' } },
states: { loading: { entry: ({ input }) => /* ... */ } }
});No migration is required if you didn't use this feature.
6. Typed actor.trigger
New in v6. Actors expose a trigger namespace - a typed dispatcher generated from schemas.events - alongside the existing actor.send(...):
const actor = createActor(machine).start();
actor.trigger.INC({ by: 5 }); // type-checked: must be { by: number }
actor.trigger.RESET(); // payload-less event - no argsactor.send(...) keeps working unchanged.
7. Async actors: fromPromise → createAsyncLogic
fromPromise was renamed to createAsyncLogic and now supports id, timeout, an AbortSignal, an event emitter, and durable steps via enq.step(key, exec).
// v5
import { fromPromise } from 'xstate';
const fetchUser = fromPromise(async ({ input }: { input: { id: string } }) => {
const r = await fetch(`/users/${input.id}`);
return r.json();
});// v6
import { createAsyncLogic } from 'xstate';
const fetchUser = createAsyncLogic({
id: 'fetchUser',
timeout: '10s', // ms or ISO8601 duration
run: async ({ input, signal, self }, enq) => {
const user = await enq.step('fetch', () =>
fetch(`/users/${input.id}`, { signal }).then((r) => r.json())
);
enq.emit({ type: 'userLoaded', user });
return user;
}
});enq.step(key, exec) records each step's outcome on the snapshot under effects[key], so on rehydration the step is not re-executed - the previous result is replayed. This makes async logic durable across restarts.
When timeout elapses, the logic aborts and rejects with TimeoutError (also exported):
import { TimeoutError } from 'xstate';Other logic helpers
import {
createLogic, // stateful, transition-style logic with enq (see §8)
createAsyncLogic, // promise / async with timeout + signal + step
createCallbackLogic, // typed callback factory
createObservableLogic,
createEventObservableLogic,
createListenerLogic, // listen to streams; map to events
createSubscriptionLogic // store subscription helpers
} from 'xstate';8. createLogic: stateful actor logic
New in v6. A lightweight alternative to a full state machine when you want an actor with custom context, events, and effects but no hierarchical states or transitions. The run function is invoked for every received event and returns either nothing or a partial snapshot update. Its initial and subsequent transitions return the same executable-effect objects as machines.
import { createLogic, createActor } from 'xstate';
const counterLogic = createLogic({
id: 'counter',
context: { count: 0 },
run: ({ context, event }, enq) => {
if (event.type !== 'inc') return;
enq.emit({ type: 'counted' });
return { context: { count: context.count + 1 } };
}
});
const actor = createActor(counterLogic).start();
actor.send({ type: 'inc' });
actor.getSnapshot().context.count; // 1context may be a value or a factory ({ input }) => TContext.
createLogic accepts type-only schemas.input and schemas.output to type input, returned output, InputFrom, and OutputFrom.
enq for createLogic (different from createAsyncLogic's) exposes:
| Method | Purpose |
|---|---|
enq.emit(event) | Emit an event observable via actor.on(...) |
enq.raise(event) | Raise an internal event for this logic's own run |
enq.sendBack(event) | Send an event back to the parent actor |
enq.effect(exec) | Run a one-shot side effect; the optional return is a cleanup function |
enq.effect(key, exec) | Keyed effect - started once and tracked on the snapshot under effects[key]; not re-run on subsequent transitions; cleanup runs when the actor stops |
Keyed effects are how durable subscriptions are wired up - createListenerLogic and createSubscriptionLogic build on this pattern.
The return value (if provided) is a partial update to the snapshot:
type LogicPatch<TContext, TOutput, TInput> = Partial<{
context: TContext;
input: TInput | undefined;
status: 'active' | 'done' | 'error' | 'stopped';
output: TOutput;
error: unknown;
effects: Record<string, LogicEffectState>;
}>;9. enq.listen and enq.subscribeTo
Two new spawn-companion APIs make wiring up child-to-parent communication declarative.
enq.listen(ref, eventType, mapper) subscribes to emitted events from a spawned actor, with optional wildcard support, and dispatches a derived event back to the parent:
entry: (_, enq) => {
const child = enq.spawn(childLogic, { id: 'child' });
enq.listen(child, 'data.*', (ev) => ({
type: 'CHILD_DATA',
payload: (ev as any).value
}));
};enq.subscribeTo(ref, mappers) subscribes to the child's snapshot stream (or to done/error/snapshot lifecycle events):
entry: (_, enq) => {
const child = enq.spawn(asyncLogic, { id: 'fetch' });
enq.subscribeTo(child, {
done: (output) => ({ type: 'FETCH_DONE', output }),
error: (err) => ({ type: 'FETCH_ERROR', err })
});
};Both return an ActorRef that can be stopped via enq.stop(ref).
10. interpret and Interpreter removed
// v5
import { interpret, Interpreter } from 'xstate';
const service = interpret(machine).start();
// v6
import { createActor } from 'xstate';
const actor = createActor(machine).start();createActor already existed in v5; v6 removes the legacy alias.
11. invoke.src resolves actor logic directly
In v5 you typically referenced an actor by string and registered it via setup({ actors: { ... } }) or the second arg to createMachine. In v6 the named source map is actors, and you may pass the logic object directly to invoke.src:
// v5
invoke: {
src: 'fetchUser',
input: ({ event }) => ({ id: event.userId })
}
// v6
invoke: {
src: fetchUserLogic, // direct reference
input: ({ event }) => ({ id: event.userId })
}String IDs still work for invoke.src when the actor is registered on createMachine({ actors: { ... } }) directly or supplied via machine.provide({ actors: { ... } }). Spawning accepts actor logic, not a string ID.
Persistence differs by API. Invoked children always persist and rehydrate: inline invoke.src logic receives a synthetic source identity resolved back through the machine config. Children spawned from a context: ({ spawn }) => ... initializer resolve registered logic back to its source name and persist. Children spawned with enq.spawn(...) currently have no source identity — getPersistedSnapshot() throws An inline child actor cannot be persisted. in development, even for registered logic. Prefer invoke when a child must survive persistence.
invoke.src may also be a function resolving to logic or to a registered name: src: ({ actors, context, event, self }) => actors.fetchUser.
An invoke may declare its own timeout / onTimeout (independent of state-level timeout): when the timeout elapses before the invoked actor completes, the onTimeout transition is taken and the invocation is cancelled.
Sending to the parent
The old sendParent action creator is gone. The parent ref is available on args:
// v5
on: {
FORWARD_DEC: {
actions: [sendParent({ type: 'DEC' })];
}
}// v6
on: {
FORWARD_DEC: ({ parent }, enq) => {
enq.sendTo(parent, { type: 'DEC' });
};
}Spawning children
// v5
entry: spawnChild('childMachine', { id: 'child', input: { x: 1 } });
// v6
entry: (_, enq) => {
const ref = enq.spawn(childMachine, { id: 'child', input: { x: 1 } });
};Access children inside transitions via ({ children }):
on: {
PING_CHILD: ({ children }, enq) => {
enq.sendTo(children.child, { type: 'PING' });
};
}12. Internal events
Events listed in internalEvents can be raised internally but are rejected when sent from outside the actor. Wildcard patterns are supported.
const machine = createMachine({
schemas: {
events: {
START: z.object({}),
tick: z.object({}),
'change.value': z.object({ value: z.string() })
}
},
internalEvents: ['tick', 'change.*'] as const,
initial: 'idle',
states: {
idle: {
on: {
START: (_, enq) => {
enq.raise({ type: 'tick' });
},
tick: 'done'
}
},
done: {}
}
});
const actor = createActor(machine).start();
actor.send({ type: 'tick' }); // throws: Internal event "tick" cannot be sent to actor "…" from outside.13. Choice states
A new state type: 'choice' provides branch routing - equivalent to a transient always block but more explicit. A choice state declares a single choice function that resolves to a target:
// v6
states: {
routing: {
type: 'choice',
choice: ({ context }) => {
if (context.isVip) return { target: 'vipFlow' };
if (context.overBudget) return { target: 'review' };
return { target: 'standardFlow' };
}
},
vipFlow: {},
review: {},
standardFlow: {}
}The function must resolve to a target - returning undefined throws "must resolve to a target".
Choice states are entered automatically by routing into them. They cannot themselves declare invoke, after, on, or entry/exit (machine creation rejects these).
14. State and async timeouts
State nodes accept a timeout and an onTimeout transition. Unlike after, the timer is cancelled if the state is exited by any other means, and the two coexist as independent timers.
states: {
waiting: {
timeout: 5000, // or any accepted duration string
onTimeout: { target: 'escalated' },
on: { APPROVE: { target: 'approved' } }
},
approved: {},
escalated: {}
}createAsyncLogic accepts timeout with the same units, and aborts the run on expiry.
Accepted duration formats
State timeout and createAsyncLogic({ timeout }) accept number (ms) or string in one of these forms:
| Form | Example | Notes |
|---|---|---|
| Milliseconds | '250ms' | integer + ms suffix |
| Seconds | '5s', '1.5s' | integer or decimal + s suffix |
| ISO 8601 duration | 'PT5S', 'PT1M30S', 'PT2H', 'P1D', 'P1W', 'P1DT12H' | always starts with P |
Plain '5m', '1h', '1d', '1w' (without the P/PT prefix) are not accepted - use the ISO 8601 form for anything beyond seconds and milliseconds.
after delays accept the same duration formats. A string delay is resolved against the machine's named delays first; only strings that don't match a named delay are parsed as durations.
15. Always (eventless transitions): function form
always accepts the same inline-function shape as event transitions:
// v5
always: [
{ target: 'morning', guard: ({ context }) => context.hour < 12 },
{ target: 'afternoon', guard: ({ context }) => context.hour < 18 },
{ target: 'evening' }
];// v6
always: ({ context }) => {
if (context.hour < 12) return { target: 'morning' };
if (context.hour < 18) return { target: 'afternoon' };
return { target: 'evening' };
};Transition arrays are not accepted by the authoring APIs. Use a single
transition function to select among targets. Serialized transition arrays are
still accepted by createMachineFromConfig(...).
16. Removed top-level exports
These exports have been removed from xstate:
- Action creators (entire
actions.tsmodule):assign,raise,sendTo,sendParent,forwardTo,emit,log,cancel,enqueueActions,spawnChild,stopChild,stop, plus their*Action/*Paramstypes - Guard combinators and helpers:
and,or,not,stateIn - Guard types:
GuardPredicate,GuardArgs - Service helpers:
interpret,Interpreter, and theInterpreterFromtype SetupReturn(no longer re-exported)- Promise actor logic surface:
fromPromise,PromiseActorLogic,PromiseActorRef,PromiseSnapshot - Transition actor logic surface:
fromTransition,TransitionActorLogic,TransitionActorRef,TransitionSnapshot - Inspection-event subtypes:
InspectedActionEvent,InspectedActorEvent,InspectedEventEvent,InspectedMicrostepEvent,InspectedSnapshotEventare gone. The remainingInspectionEventtype was reshaped: itstypeis now only'@xstate.actor' | '@xstate.transition'(a discriminated union ofActorInspectionEventandTransitionInspectionEvent, both also exported). - The
devToolsactor option and thexstate/dev,xstate/actions, andxstate/guardssubpath exports - v5 definition/config types:
AnyState,StateMachineDefinition,StateNodeDefinition,StatesConfig,MachineOptions,ExecutableActionsFrom, and related internals. The config typesMachineConfig,StateNodeConfig,InvokeConfig, andTransitionConfigOrTargetare re-exported with their v6 shapes - same names, different structure. transition()/initialTransition()now returnExecutableActionObject[]for effects; hand-written actor logictransitionandinitialTransitionreturn[snapshot, effects]tuples whose effects each provideexec(runtime?).ActorLogic.executeEffectshas been removed. Actor logic returns executable effects directly.
SpecialTargets (the Parent/Internal enum) is still exported from 'xstate' via types.ts and continues to work.
These exports have been added:
setup(reshaped - see §4) andcreateSystem(...).setup(...)for typed system registriescreateFSMand its relatedFSMActorLogic/FSMConfig/FSMSnapshottypes for flat, actor-compatible finite state machinescreateStateConfigcheckStateIncreateLogic,createAsyncLogic,createCallbackLogic,createObservableLogic,createListenerLogic,createSubscriptionLogicTimeoutError- Serialization surface (see §21):
createMachineFromConfig,machineConfigToJSON, and theMachineJSON/StateNodeJSON/TransitionJSON/ActionJSON/GuardJSON/InvokeJSONtypes; machines serialize viaserializeMachine(machine) - Config types (v6 shapes):
MachineConfig,StateNodeConfig,InvokeConfig,TransitionConfigOrTarget,Sources,InferEvents,WidenLiterals isBuiltInExecutableActionexecuteEffectsActorSystemRuntimeActorTermination- Persistence/versioning surface:
machineVersionsand its related snapshot migration and event adaptation types - Executable effect types:
BaseExecutableActionObject,CustomExecutableActionObject,ExecutableActionObject,ExecutableActionObjectFromLogic,BuiltInExecutableActionObject,SpecialExecutableAction,StartExecutableActionObject,RaiseExecutableActionObject,SendToExecutableActionObject,CancelExecutableActionObject,StopExecutableActionObject,TerminateExecutableActionObject ActorLogic.start(snapshot, scope, options?)receivesoptions.restoredso logic can distinguish restoration from a fresh start.actor.select(selector)- derived, subscribable views
17. Removed packages
@xstate/immer - removed
The immer action creator no longer exists. Update context immutably from your inline function:
// v5
import { immerAssign } from '@xstate/immer';
on: {
ADD: {
actions: immerAssign((ctx, ev) => {
ctx.todos.push(ev.todo);
});
}
}// v6
on: {
ADD: ({ context, event }) => ({
context: { todos: [...context.todos, event.todo] }
});
}If you want Immer-style drafts, call Immer's produce yourself inside the inline function.
@xstate/inspect - removed
The standalone inspector package and its inspect() entry point are gone. Inspection now flows through:
actor.subscribe(observer)- the snapshot stream- the
inspectoption oncreateActor- supplied at the system root and called for all transitions and microsteps in the actor tree
import { createActor } from 'xstate';
import type { InspectionEvent } from 'xstate';
createActor(machine, {
inspect: (ev: InspectionEvent) => {
// ev.type is '@xstate.actor' or '@xstate.transition' in v6
// ev.actorRef is always set; ev.event, ev.snapshot, ev.sourceRef, ev.targetRef are on '@xstate.transition'
}
}).start();The granular v5 inspection-event subtypes (@xstate.event, @xstate.snapshot, @xstate.action, @xstate.microstep) are gone. v6 emits two kinds: @xstate.actor (actor topology: identity and parent) and @xstate.transition (every transition facet: event, snapshot, source, target, and the microsteps array). Both carry the root actor's globally unique sessionId as rootId; actor refs also have globally unique sessionIds. Microsteps are carried on the @xstate.transition event rather than emitted as a separate event.
18. Framework bindings
React (@xstate/react)
useActor no longer rehydrates from a stopped root via the removed stopRootWithRehydration helper - it now creates a fresh actor when the previous one has stopped:
// API surface unchanged
const [snapshot, send, actor] = useActor(machine, options);The hook signatures remained stable. Most application-level changes you make will be in the machine itself, not the hook.
Vue / Svelte / Solid
Bindings updated to track the new Snapshot shape and createActor return type. No new hook signatures, but consuming components that referenced removed xstate exports (like assign) need to be migrated.
19. assertEvent
Still exported, and it still type-narrows. It works with schemas-typed events from createMachine:
on: {
greet: ({ event }, enq) => {
enq(() => {
assertEvent(event, 'greet');
console.log(event.message); // typed as string
});
};
}20. Persistence / rehydration
Persisted snapshots round-trip through JSON.stringify and rehydrate via createActor(machine, { snapshot }). The internal shape changed, so v5 persisted snapshots are not binary-compatible with v6 - drain or migrate stored state during your rollout.
const snapshot = actor.getPersistedSnapshot();
const json = JSON.stringify(snapshot);
// later
const restored = JSON.parse(json);
const actor2 = createActor(machine, { snapshot: restored }).start();Child actors, async logic with effects, and listener-resume semantics are all part of the rehydrated surface.
Snapshot versioning
A machine may declare an id and version. When set, they are stamped onto every
persisted snapshot as machine: { id, version } (and survive JSON.stringify), so
a rollout can detect and migrate older snapshots instead of feeding them to an
incompatible machine. The legacy top-level version is also retained:
const machine = createMachine({
version: '1',
initial: 'a',
states: { a: {} }
});
const persisted = actor.getPersistedSnapshot();
(persisted as any).version; // '1'
(persisted as any).machine; // { id: '(machine)', version: '1' }Machines without a version produce snapshots with no version key.
Pass { id, version, snapshotSchema, eventSchema } to retain Standard Schema
contracts for a historical version without retaining its old executable machine.
Either schema may be omitted when only one kind of historical data exists.
machineVersions() infers exact snapshot migration and event adapter inputs from
the corresponding schema. Targets must be backed by actual machines, which also
remain supported directly as entries. A '*' handler may asynchronously handle
unknown data.
Versioned machines expose the same snapshotSchema and eventSchema fields as
historical descriptors. machineVersions() therefore consumes one source
interface; executable-machine detection is only used to constrain to.
To migrate snapshots created before versioning was adopted, describe the old
snapshot as a version and pass { unversioned: '<old-version>' } to
machineVersions(). This fallback applies only to snapshots without version
metadata. parseSnapshot() rejects explicit unknown versions;
migrateSnapshot() may handle them with '*'.
Event history adaptation is a separate operation. Use
machineVersions().adaptEvents(events, { from, to, adapters }) to transform a
whole source history into target-version events. Exact retained-version
adapters are typed; '*' receives unknown events. Adaptation validates target
event schemas when available but does not replay events or create a snapshot.
Descriptors with an eventSchema provide exact event adapters. The schema
validates complete event objects rather than machine schemas.events payloads.
Durable timers
Pending delayed deliveries are explicit in snapshot.timers. Each declaration
contains its stable ID, delay, delivery type, event, and logical target; it contains no
wall-clock timestamps or native timeout handles. Timer runtimes implement
scheduleTimer(source, id, delay) / cancelTimer(source, id) and deliver
{ type: 'xstate.timer', id } to the source when due. Consuming that input
removes the declaration and emits the real delivery effect.
Final and explicitly stopped snapshots contain no pending timers; their
transitions emit ordered cancellation effects for any remaining declarations.
Persisted delayed sends preserve self, parent, and active-child relationships
without rebinding stopped actors by ID.
A locally rehydrated actor restarts each timer with its declared delay. Durable
hosts that need wall-clock restoration persist scheduledAt / dueAt
separately from the machine snapshot.
Terminal actor effects
When actor logic first returns a terminal snapshot, it emits a final
@xstate.terminate effect. For machines this follows exit actions, child stops,
timer cancellations, and deferred child starts. Runtimes implement
terminateActor(actor, termination) to publish the terminal snapshot, close the
actor, and notify its parent; the default actor system preserves the same
ordering.
21. Machine-as-data: serialization, JSON configs, SCXML
v6 treats the machine definition as data with an explicit boundary around runtime sources:
Machine → JSON
serializeMachine(machine) - a dedicated, tree-shakeable function - returns
the JSON-serializable definition. Inline functions are captured as
{ "@code": string, "@lang": "ts" } code expressions (the CodeExpression
type). Values that cannot be represented as data - actor logic objects,
runtime schemas, class instances - are omitted. Root-level actions,
guards, actors, and delays source maps are omitted when their values are
functions; usage sites (such as invoke.src: 'worker' or
guard: { type: 'canFinish' }) survive as the contract a revived machine must
fulfill.
const json = JSON.stringify(serializeMachine(machine)); // never throwsJSON → machine
createMachineFromConfig(config) - exported from 'xstate' - builds a
machine from a plain JSON config using serialized action objects
({ type: '@xstate.raise', event: ... }, { type: '@xstate.assign', ... },
{ type: '@xstate.emit', ... }, custom { type, params } actions) and
{ type, params } guard references. Runtime sources are supplied via the
second argument: createMachineFromConfig(json, { actions, guards, actors, delays, evaluators }),
where evaluators (keyed by language, e.g. { ts: ... }) revives @code
expressions. Data-only machines built this way round-trip losslessly:
createMachineFromConfig(JSON.parse(JSON.stringify(serializeMachine(machine)))).
import { createMachineFromConfig, type MachineJSON } from 'xstate';
const def: MachineJSON = {
initial: 'idle',
states: {
idle: { on: { START: { target: 'running' } } },
running: { entry: [{ type: '@xstate.raise', event: { type: 'go' } }] }
}
};
const machine = createMachineFromConfig(def);Note: serialized-action vocabulary does not yet cover every v6 feature -
state input, enq.listen/subscribeTo, and choice functions have
no JSON representation.
SCXML
toMachineJSON(scxml)- parse SCXML XML to a plain JSON machine configtoMachine(scxml)- parse SCXML XML directly to aStateMachine
These remain repo-internal and are not exported from xstate (they pull in
an XML parser).
22. actor.select
New in v6. actor.select(selector, equalityFn?) returns a subscribable,
memoized Readable<T> ({ get, subscribe }) that only notifies when the
selected value changes (default comparison Object.is).
Use actor.getSnapshot() to read the full current snapshot directly.
const count = actor.select((snapshot) => snapshot.context.count);
count.get(); // current selection
const sub = count.subscribe((c) => console.log(c)); // only fires on changeA noop event that doesn't change the selected value produces no notification.
23. Route states
New in v6. A state node with an explicit id may declare a route,
marking it as directly navigable. Sending { type: 'xstate.route', to: '#id' }
transitions straight to that state - without an event-to-target transition
wired up at the source.
const machine = createMachine({
id: 'flow',
initial: 'a',
states: {
a: {},
b: { id: 'b', route: {} }, // always navigable
c: { id: 'c', route: () => false }, // currently blocked
d: { id: 'd', route: ({ context }) => context.ready } // conditional
}
});
const actor = createActor(machine).start();
actor.send({ type: 'xstate.route', to: '#b' }); // -> 'b'The route value is either a config object or a transition-style
function acting as guard and resolver: returning undefined/false blocks
the route; returning true or a config object (optionally with context,
input, reenter, meta) allows it. A state without route cannot be routed
to.
24. Actor registry
New in v6 (public). Actors can be looked up by registryKey from
the shared system, so distant actors can find each other without threading refs
through the tree:
const system = createSystem({
registry: {
receiver: childLogic
}
});
const machine = system.setup().createMachine({
invoke: { src: childLogic, registryKey: 'receiver' }
});
const actor = system.createActor(machine, { registryKey: 'root' }).start();
actor.system.get('receiver'); // the invoked child's ActorRef (or undefined)
actor.system.get('root'); // the root actor itself
actor.system.getAll(); // Partial map of all registered actorsregistryKey is assigned via invoke.src's registryKey, spawn's
registryKey option, or system.createActor(machine, { registryKey }) for the
root. Entries are removed when their actor stops. With
createSystem({ registry }), registryKey is checked against the registry and
registered actor logic. Transition functions receive the same typed system,
so system.get('receiver') is available without casts.
Migration checklist
- Replace every
assign({...})with an inline function returning a shallow{ context: {...} }patch - Replace every
raise,sendTo,sendParent,forwardTo,emit,log,cancel,spawnChild,stopChildwith the correspondingenq.*call - Replace
enqueueActions(...)with a regular inline(args, enq) => { ... }function - Replace
and/or/notguard combinators with plain JS inside the guard or inline transition function - Replace
stateIn(...)withcheckStateIn(self.getSnapshot(), ...) - Replace
interpret(machine)withcreateActor(machine); removeInterpretertype imports - Replace
fromPromise(...)withcreateAsyncLogic({ run: ... }) - Replace
types: {} as { ... }withschemas: { ... }(Zod / Standard Schema) - If you used
eventsas a union, restructure to a map keyed by type - Move
actions/guards/actors/delaysoff ofsetup({ ... })and ontocreateMachine({ ... })(ormachine.provide({ ... })) - Audit
invoke.srcreferences -srcmay be a logic object, a registered name, or a resolver function - Drop dependencies on
@xstate/immerand@xstate/inspect; update inspection toactor.subscribe, theinspectoption, or@statelyai/inspect - Remove imports of
SetupReturn,GuardArgs,GuardPredicate,Inspected*Event,PromiseActorLogic, andfromPromise(usecreateAsyncLogic) - Drain/migrate any v5 persisted snapshots - the v6 snapshot shape is not binary-compatible
- Run
pnpm typecheckandpnpm testto surface remaining issues
Worked example: a small machine
// v5
import { createMachine, createActor, assign, raise } from 'xstate';
const machine = createMachine({
types: {} as {
context: { count: number };
events: { type: 'INC' } | { type: 'RESET' } | { type: 'DONE' };
},
context: { count: 0 },
initial: 'idle',
states: {
idle: {
on: {
INC: {
actions: assign(({ context }) => ({ count: context.count + 1 })),
target: 'idle'
},
RESET: {
actions: [assign({ count: 0 }), raise({ type: 'DONE' })]
},
DONE: 'finished'
}
},
finished: { type: 'final' }
}
});// v6
import { z } from 'zod';
import { createMachine, createActor } from 'xstate';
const machine = createMachine({
schemas: {
context: z.object({ count: z.number() }),
events: {
INC: z.object({}),
RESET: z.object({}),
DONE: z.object({})
}
},
context: { count: 0 },
initial: 'idle',
states: {
idle: {
on: {
INC: ({ context }) => ({
context: { count: context.count + 1 }
}),
RESET: ({ context }, enq) => {
enq.raise({ type: 'DONE' });
return { context: { count: 0 } };
},
DONE: 'finished'
}
},
finished: { type: 'final' }
}
});
const actor = createActor(machine).start();
actor.trigger.INC();
actor.trigger.RESET();