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. To install v6 next to v5 and to automate part of the migration, see §27.
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 |
sendTo('child', ev) with no running child | dead letter with reason 'missingTarget'; the sender stays active (§26) |
tsTypes (typegen) | removed; declare types with schemas (§3) |
machine.implementations | machine.sources |
snapshot._nodes | snapshot.nodes |
systemId (createActor, invoke, spawn options) | registryKey; look up with system.get(registryKey) |
spawn('name') in a context factory | spawn(actors.name) |
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/internalEvents/input/output/emitted/meta/tags accept Zod (or any Standard Schema) for TypeScript inference and runtime-readable metadata. |
| Setup state contracts | setup({ states }) can type state schemas and declare structural defaults such as state type, initial target, history target, ID, and route behavior. |
| 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 | schemas.internalEvents: { tick, 'change.*' } - events that can be raised inside the machine but rejected by the public actor protocol. |
| 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; machineVersions() migrates older snapshots. Snapshots restore leniently, with no format version field. |
| 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 patches. XState merges a patch into the current context at the top level ({ ...context, ...patch }): omitted keys keep their current values, and a nested object in the patch replaces the current value of that key. If a transition targets a state with a narrower schemas.context, the patch type requires the keys needed to satisfy that 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(source, opts?) | Spawn from actor logic or a typed registered name; 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.isVip)) {
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.
A leftover types key is a compile error in v6 rather than dead configuration, so the compiler finds every one of them for you. xstate-codemod migrate --transform types-to-schemas rewrites them.
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
internalEvents: { [eventType: string]: ZodSchema }, // private event map
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
transitionMeta: ZodSchema, // per-transition 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. Runtime validation is opt-in throughstandardSchemaValidator()or the machine's event schema; ordinaryactor.send(...)calls do not automatically validate payloads. Internal schemas also define the private event protocol.
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.
Typegen removed
tsTypes and generated *.typegen.ts files are not supported in v6. Declare types with schemas. In development builds, createMachine(...) throws on a leftover tsTypes key with the message "tsTypes" (typegen) was removed. Declare contracts under `schemas` (or `setup({ schemas })`).
Per-state context types
v6 has no typestates. To type context per state, declare a schemas.context for that state in setup({ states }). The state schema refines the root context schema, so it declares only the fields that the state narrows:
import { assertEvent, createActor, setup } from 'xstate';
import { z } from 'zod';
const machine = setup({
schemas: {
context: z.object({ user: z.string().nullable() }),
events: { LOAD: z.object({ name: z.string() }) }
},
states: {
idle: { schemas: { context: z.object({ user: z.null() }) } },
success: { schemas: { context: z.object({ user: z.string() }) } }
}
}).createMachine({
initial: 'idle',
context: { user: null },
states: {
idle: {
on: {
LOAD: ({ event }) => ({
target: 'success',
context: { user: event.name }
})
}
},
success: {
entry: ({ context, event }) => {
context.user; // string
assertEvent(event, 'LOAD');
event.name; // string
}
}
}
});
const snapshot = createActor(machine).start().getSnapshot();
snapshot.context.user; // string | null
if (snapshot.matches('success')) {
snapshot.context.user; // string
}This is a different model from typestates, not a translation of them:
- Actions and transitions declared on a state see that state's narrowed context. A transition into
successmust return acontextpatch that satisfies thesuccessschema. snapshot.contexthas the root context type.snapshot.matches(...)narrows it. For a parallel state,matchesnarrows context for each region named in the matched value.- Narrowing applies to context only.
entry,exit, and transition functions still receive the machine's event union, so useassertEventto narrowevent. - The schemas are compile-time types. XState validates context against them at runtime only when runtime validation is enabled.
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 contracts. A contract can provide state-level schemas plus structural metadata and defaults such as type, initial, history, target, id, and route. This types createMachine and createStateConfig, including the initial: { target, input } form and transitions targeting those states. Setups that declare only schemas remain permissive for compatibility.
// 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: (ready: boolean) => 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 work for invoke.src and transition spawning when the actor is registered on createMachine({ actors: { ... } }) directly or supplied via machine.provide({ actors: { ... } }). enq.spawn('worker') is checked against that actor map and retains exactly that source identity. The context initializer's spawn continues to accept actor logic.
Invoked children always persist and rehydrate: inline invoke.src logic receives a synthetic source identity resolved back through the machine config. spawn(actors.worker) in a context initializer and both enq.spawn(actors.worker) and enq.spawn('worker') in a transition retain a registered source key and persist. provide(...) may replace the implementation under that key. When multiple keys share a logic value, the string form preserves the selected key; the logic form uses the first registered key. Raw inline logic that is not registered has no reconstructable source identity, so getPersistedSnapshot() throws while such a spawned child exists.
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 declared in schemas.internalEvents can be raised internally but
are rejected by the public actor protocol. Wildcard patterns are supported.
const machine = createMachine({
schemas: {
events: {
START: z.object({})
},
internalEvents: {
tick: z.object({}),
'change.*': z.object({ value: z.string() })
}
},
initial: 'idle',
states: {
idle: {
on: {
START: (_, enq) => {
enq.raise({ type: 'tick' });
},
tick: 'done'
}
},
done: {}
}
});
const actor = createActor(machine, {
onRejectedEvent: (rejection) => {
rejection.reason; // 'internalEvent'
}
}).start();
actor.send({ type: 'tick' }); // not delivered; does not throwSending an internal event from outside the actor does not throw. The event is
rejected as a dead letter with reason 'internalEvent': it is reported to
onRejectedEvent, and development builds log a warning.
Raised, self-targeted and transition-handler event types include both schema
maps. actor.send(...) and actor.trigger include only schemas.events.
The top-level internalEvents descriptor list from the v6 alphas was removed.
Move each listed event's schema from schemas.events to
schemas.internalEvents. In development builds, a config with a top-level
internalEvents key throws an error naming schemas.internalEvents.
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
stateactor option. Usesnapshot:createActor(machine, { snapshot: persisted }). In development builds, passingstatethrows an error namingsnapshot. - The top-level
internalEventsmachine config key. Useschemas.internalEvents; see Internal events. - 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.- Deprecated snapshot helpers:
getInitialSnapshot(logic, input?)andgetNextSnapshot(logic, snapshot, event). UseinitialTransition(logic, input?)andtransition(logic, snapshot, event); the snapshot is the first element of the returned tuple. - Deprecated type aliases:
NoInfer(use the built-inNoInfer),AnyInterpreter(useAnyActor), andResolvedStateMachineTypes xstate/graph:getStateNodes(stateNode)(all descendant state nodes) is renamed togetDescendantStateNodes(stateNode). The rootgetStateNodes(stateNode, stateValue)export fromxstateis unchanged.xstate/graph:createTestModel,TestModel, and the types used only by them (TestModelOptions,TestParam,TestPath,TestPathResult,TestStepResult,TestMeta,EventExecutor), pluscreateShortestPathsGenandcreateSimplePathsGen.xstate/graphnow only generates paths. UsetestPaths()from@xstate/testto run them; see Model-based testing.- The
xstate/scxmlentry point.createMachineFromSCXMLmoved to the separate@xstate/scxmlpackage (npm i @xstate/scxml).
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) andcreateSystemfor typed system registries- Setup state contract types:
SetupStateSchema,SetupStateSchemas,SetupStateType createStateConfigcheckStateIncreateEmptyActor,createLogic,createAsyncLogic,createCallbackLogic,createObservableLogic,createEventObservableLogic,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 - Runtime/effect surface:
executeEffects,isBuiltInExecutableAction,getEffectDescriptor,deliverEvent,runStep,stopActor,terminateActor, and the relatedEffectDescriptor,ActorSystemRuntime, andActorTerminationtypes - 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
The xstate/fsm subpath (not the root xstate entry) exports the pure createFSM API and its FSM* types, plus a lightweight
setup/types facade for typed events, context, and state snapshots.
fsm.transition(snapshot, event) returns [nextSnapshot, effects], the same
protocol as other actor logic, so createActor(fsm) runs an FSM as an actor.
See compact finite state machines for its exact supported surface.
Renamed members and identifiers
machine.implementationsis nowmachine.sources, and theMachineImplementationsFromtype is nowMachineSourcesFrom.snapshot._nodesis nowsnapshot.nodes. It lists the active state nodes.- The
systemIdoption is nowregistryKey, increateActor(logic, { registryKey }),invoke: { registryKey }, andenq.spawn(logic, { registryKey }).actorRef.systemIdis nowactorRef.registryKey.system.get(registryKey)looks the actor up (see §24).systemIdis not accepted as an option. - The
spawnfunction passed to acontextfactory accepts actor logic only. Replacespawn('worker')withspawn(actors.worker), using theactorsargument of the same factory. - The
createMachinetypes do not accept transition arrays inonoralways. Select among targets in one transition function (see §15). JSON configs passed tocreateMachineFromConfig(...)still accept transition arrays. - Actor
sessionIds are unique across actor systems and have the form<systemId>:<n>, wheresystemIdis random. v5 usedx:<n>. Code that parsed or comparedsessionIds across systems must not rely on the format.
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.
Persisted format
getPersistedSnapshot() returns a JSON-shaped object, and createActor(machine, { snapshot }) restores it leniently: the snapshot has no format version field, and XState reads the fields it recognizes. Snapshots persisted by v6 alphas restore without a migration step when the machine's states still resolve. Changes to your own states, context, and children are versioned with the machine version and migrated with machineVersions(), described below.
The host serializes the payload. Development builds warn when context, output, error, or state input holds a value that does not survive a JSON round-trip: functions, symbols, BigInt, Map, Set, cycles, NaN, and Infinity. Persisting a snapshot whose context contains a circular reference throws an error that names the actor and the context path. See Persistence for the format rules.
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 timer persisted from a running actor carries its wall-clock start
(startedAt), and rehydrating schedules the remaining time toward the original
deadline. Pure-transition snapshots carry no timestamp, so rehydrating one
restarts each timer with its declared delay; durable hosts own timer
scheduling through the system runtime.
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
createMachineFromSCXML(scxml) creates an XState machine from an SCXML
document. Install and import it from the separate @xstate/scxml package so the
XML parser does not become a dependency of xstate.
import { createMachineFromSCXML } from '@xstate/scxml';
const machine = createMachineFromSCXML(scxml);SCXML uses a private compiler representation rather than MachineJSON. See
SCXML for resource resolution and usage.
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.
25. Actor ownership
A child actor has one of two owners.
An invoked actor is owned by the state that declares it. XState starts it when the state is entered and stops it when the state is exited. Its result arrives through that state's onDone and onError transitions.
A spawned actor is owned by the actor that spawned it, not by a state. It keeps running after the state that spawned it is exited. It stops when you call enq.stop(ref), when its parent stops, or when its parent errors. The parent receives its completion and failure as xstate.done.actor and xstate.error.actor events carrying its actorId. Use enq.subscribeTo(ref, mappers) or enq.listen(ref, type, mapper) to map its lifecycle or emitted events to parent events (see §9).
states: {
active: {
invoke: { id: 'poller', src: pollerLogic },
entry: (_, enq) => {
enq.spawn(workerLogic, { id: 'worker' });
},
on: { NEXT: { target: 'inactive' } }
},
inactive: {}
}After NEXT, snapshot.children contains worker and no longer contains poller.
26. Missing send targets and error precedence
Sending to a missing target
In v5, sendTo('worker', event) threw when no child worker existed, and sendParent(event) threw in a root actor. The throw put the sending actor into an error state.
In v6, enq.sendTo(...) to a missing target does not error the sender. A missing target is an undefined ref, a child id with no running child, or parent in a root actor. The event becomes a dead letter with reason 'missingTarget':
- The sender stays
active, and stateonErrorhandlers do not run. - The root actor's
onRejectedEventoption receives the event withreason: 'missingTarget'and the requestedtargetId. system.onRejectedEvent(listener)listeners receive the same rejection. Inspectors receive no separate event; the inspection protocol is only@xstate.actorand@xstate.transition.- Development builds log a warning such as
Actor "sender" sent event "PING" to missing target "worker"; the event was not delivered (missingTarget).
A machine that sends to an optional child checks for the child before sending:
// v5
on: {
PING: {
guard: ({ self }) => self.getSnapshot().children.worker !== undefined,
actions: sendTo('worker', { type: 'PING' })
}
}// v6
on: {
PING: ({ children }, enq) => {
if (children.worker) {
enq.sendTo(children.worker, { type: 'PING' });
}
}
}Without the check, the send is a dead letter and development builds warn. When worker is absent, the v6 function enqueues nothing and returns undefined, so the event is unhandled; return {} to handle it without a transition. If a missing child indicates a bug in your application, drop the check and report missingTarget rejections from onRejectedEvent.
Error precedence
An error thrown by a transition function, an effect, or a child actor is resolved by the first step that applies:
- The
onErrorof the nearest active state that handles the error recovers it. The actor staysactive. - Otherwise the actor's status becomes
'error', and the actor stops all of its children, invoked and spawned. - Subscribers with an
errorobserver receive the error. - The error is reported once as unhandled (
reportUnhandledError) if any subscriber lacks anerrorobserver, even when another subscriber has one, or if the actor has no subscribers and no parent. Observers installed by runtime integrations (passive: true) are not counted. The report runs one macrotask later; subscribing with anerrorobserver before then suppresses it.
A failed child reaches its parent as an xstate.error.actor event with actorId and error fields, which the parent resolves by the same steps. Select one child's failure with matches: { actorId } (see Internal lifecycle events). Lifecycle and errors lists the full rules.
27. Codemod and side-by-side installs
Codemod
The @xstate/codemod package provides the xstate-codemod CLI. Review the changes with --dry, then run it again without --dry to write them:
npx @xstate/codemod migrate "src/**/*.ts" --dry
npx @xstate/codemod migrate "src/**/*.ts"It runs these transforms in order. --transform name,name selects a subset.
| Transform | Effect |
|---|---|
rename-imports | Renames interpret to createActor, Interpreter to Actor, and fromCallback, fromObservable, and fromEventObservable to their create*Logic names, including usages. Import aliases are kept. |
string-targets | Wraps bare string transition values under on, after, onDone, onError, and always in { target: '...' } inside createMachine and createStateConfig configs. |
types-to-schemas | Converts types: {} as { ... } to schemas with types<T>() entries. Events become a map only when they are written as an inline union literal. |
report-removed-apis | Reports, without rewriting, uses of assign, raise, sendTo, sendParent, forwardTo, emit, log, cancel, spawnChild, stop, stopChild, enqueueActions, and, or, not, stateIn, fromPromise, and fromTransition, with a suggested replacement. |
string-targets wraps string elements inside transition arrays but keeps the arrays. The v6 createMachine types reject transition arrays in on and always (for example, on: { X: [ … ] }), and no transform reports them. Find them by hand and replace each with one transition function (§15, §16).
The codemod does not convert action creators or guard combinators. Rewrite each reported assign, raise, sendTo, or other action creator as an inline function by hand (§1, §2). The transforms read and write imports from 'xstate' only.
Running v5 and v6 side by side
Install v6 under an npm alias next to v5:
npm install xstate-v6@npm:xstate@alphapackage.json then lists "xstate-v6": "npm:xstate@^6.0.0-alpha.<n>" next to "xstate". Import each version by its package name:
import { createMachine as createV5Machine } from 'xstate';
import { createMachine } from 'xstate-v6';Migrate one file at a time: run the codemod on it, finish the manual changes, then change its 'xstate' imports to 'xstate-v6'. When no v5 imports remain, remove the alias and install xstate@alpha as xstate.
28. Leftover v5 keys
In development builds, createMachine(...) and setup(...).createMachine(...) check hand-written configs for v5 keys that v6 would otherwise ignore or misread. Machines built with createMachineFromConfig(...) or createMachineFromSCXML(...) are not checked. Production builds skip the check.
These keys throw an error:
| v5 key | Where | v6 replacement |
|---|---|---|
cond | transition object | inline transition function; return undefined to reject the event |
guard | transition object | inline transition function; call named guards with guards.name(...) |
actions | transition object | inline transition function (args, enq) => { ... }; call named actions with enq(actions.name, params) |
string or array entry / exit | state node | a single inline function (args, enq) => { ... } |
types | machine config | schemas (or setup({ schemas })) |
tsTypes | machine config | schemas (typegen was removed) |
schema | machine config | schemas |
These keys log a warning:
| v5 key | v6 replacement |
|---|---|
services | actors (or setup({ actors })) |
activities | invoke |
predictableActionArguments | removed; effects always run in order |
preserveActionOrder | removed; effects always run in order |
strict | removed |
devTools | the inspect option on createActor(...) |
Migration checklist
- Run
xstate-codemod migrate --dry, apply it, and migrate the APIs it reports by hand (§27) - 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
- Check that persisted
contextholds only JSON values; development builds warn on functions, symbols,BigInt,Map,Set, cycles,NaN, andInfinity - Remove
tsTypesand generated*.typegen.tsfiles - Replace transition arrays in
onandalwayswith one transition function - Rename
machine.implementationstomachine.sourcesandsnapshot._nodestosnapshot.nodes; replacespawn('name')incontextfactories withspawn(actors.name) - Check for optional children before
enq.sendTo, or reportmissingTargetdead letters fromonRejectedEvent - 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();