Pure system transitions
Branch, replay and advance an actor system using immutable snapshots.
XState v6 is in beta
Experimental:
initialSystemTransition(...),systemTransition(...),advanceSystemTime(...)and theSystem*types may change in a minor release.
The experimental system transition APIs calculate immutable SystemSnapshot
values. Each returns [snapshot, externalEffects]. A system snapshot contains
actor snapshots, topology, the registry, messages, pending external work,
timers, virtual time and identity/order counters. Calculations never execute
external effects or start a live actor system.
import {
createMachine,
initialSystemTransition,
systemTransition,
advanceSystemTime
} from 'xstate';
const systemLogic = {
root: createMachine({
id: 'root',
initial: 'waiting',
states: {
waiting: {
on: { FINISH: { target: 'done' } },
after: { 2000: { target: 'early' }, 6000: { target: 'late' } }
},
early: { after: { 1000: { target: 'done' } } },
late: {},
done: {}
}
})
};
const [snapshot] = initialSystemTransition(systemLogic, { time: 0 });
const [finished] = systemTransition(systemLogic, snapshot, snapshot.root, {
type: 'FINISH'
});
const [elapsed] = advanceSystemTime(systemLogic, snapshot, { time: 4000 });
finished.actors[finished.root].snapshot.value; // 'done', at time 0
elapsed.actors[elapsed.root].snapshot.value; // 'done', at time 4000
snapshot.actors[snapshot.root].snapshot.value; // 'waiting'; input is unchangedsystemLogic contains immutable definitions: root, optional actors for
dynamic spawns, optional effects naming callable external actions, and
optional mappers naming pure listener/subscription mappers. Machine actor
sources and inline invocations are discovered automatically. Use the same
definitions when branching or replaying.
Initialize root input with { input }. id overrides its ID; executionId
namespaces effect IDs for hosts managing several executions (default: 'system').
Distinguish replay branches at the host boundary before executing effects.
registryKey registers the root for system.get lookups.
Completed macrosteps
Each call completes actor macrosteps and drains immediate inter-actor messages in FIFO order. Raised events and eventless transitions settle within their actor's macrostep. Microsteps are atomic; system snapshots contain no unfinished microstep or continuation state. A UI presenting individual completed microsteps within a macrostep needs a separate tracing interface.
Snapshot actor references are data: { $actor, incarnation }, the same shape as SerializedActorRef, with a string incarnation. Use $actor
as actorPath, for example snapshot.actors[snapshot.root].snapshot.children.worker.$actor.
Paths encode IDs; avoid concatenating unescaped IDs. Replacing an actor at the
same path allocates a fresh incarnation.
Use enq.sendTo, enq.spawn, enq.stop, enq.raise and enq.cancel inside
pure transition functions. system.get and system.getAll resolve system-owned
references; self.getSnapshot() reads the current reduction. Live methods
such as actor.send, actor.start, actor.on and actor.subscribe throw.
For enq.listen/enq.subscribeTo, register named pure mapper functions in
systemLogic.mappers; mapped communication and attachment lifetimes belong
to the snapshot too.
Subscription done/error mappings enter the owner's message queue before
the native child completion/error notification, matching live actor ordering.
Virtual time
advanceSystemTime(systemLogic, snapshot, { time }) takes an absolute destination
in milliseconds. Time must be finite and cannot move backwards. Timers record
scheduledAt, dueAt, a unique occurrence, an ordering sequence, and the
event delivered when they fire ({ type: 'xstate.timer', ...timer } for a
logical timer; see durable timers).
snapshot.timers lists them in scheduling order.
Actor incarnations prevent old timers reaching replacement actors.
Advancing the example to 4000 fires the first timer at 2000, cancels the 6000 timer, and creates a timer at 3000, which also fires. The final clock is 4000. Every timer observes its own deadline. New intermediate timers participate in that run; equal deadlines use insertion order.
Select a pending timer in a simulator with its current occurrence:
const selected = snapshot.timers.find((timer) => timer.dueAt === 6000)!;
const [next] = systemTransition(systemLogic, snapshot, selected.source.$actor, {
type: 'xstate.timer',
occurrence: selected.occurrence
});Selection advances the entire snapshot through that deadline. Earlier events
can cancel or replace the occurrence; selection never forces it to fire.
Stale selections and direct external xstate.after/xstate.timeout.state injection
throw. Delayed raises/sends, state/invocation timeouts and async-logic timeouts
share the scheduler. Ordinary events leave matching pending timers intact;
ordinary sends first drain timers due at the current time.
External work
Effects contain data: id, source, type, kind, args, and optional
params/event. They contain no exec function. Name callable machine actions
in systemLogic.effects or machine action sources so hosts receive stable
names. enq.log returns @xstate.log. Emissions return kind: 'emit' effects
and also reach system-owned listeners.
The host interprets descriptors. createStepLogic/createAsyncLogic produce
@xstate.effect, with params.key for keyed logic effects. Dispatch
using the source actor's definition and saved input; the host owns the
external implementation. Its result reenters as a correlated input:
const [resolved] = systemTransition(systemLogic, pendingSnapshot, effect.source.$actor, {
type: 'xstate.system.effect.result',
effectId: effect.id,
event: { type: 'xstate.async.resolve', data: result }
});Use xstate.async.reject with data: error for async failure. Omit event
to acknowledge without delivery. Results must match a pending effect's owner
and incarnation; duplicate, cancelled and stale results throw. Stopping or
completing actors returns @xstate.cancelEffect commands carrying
params.effectId. Hosts cancel that work. Cleanup uses @xstate.cleanup.
Acknowledgements without events can retire pending notifications, cleanup and
cancellation commands after their owner has stopped or been replaced.
Replay and bounds
System snapshots contain plain data. JSON round trips work when user context,
input, outputs, events and effect arguments are JSON-compatible. Actor references,
state-node references, errors and named mappers use data markers. Errors retain
name/message. Undefined properties follow ordinary JSON rules. Cycles,
unregistered functions, live actors, class instances, symbols, bigint and
nonfinite numbers are rejected. $actor, $stateNode, $error and
$systemMapper marker shapes are reserved. Logic versions must match on replay.
Transition functions, guards and mappers must be deterministic and pure.
Read external values from input/events and virtual time from system._clock.now().
Do not read wall time, randomness or external mutable state during calculation.
Custom logic must implement pure initialTransition/transition operations;
runtime start hooks are never executed.
Calls bound message/timer chains to 10,000 steps. Initialization and advancement
accept maxSteps. Exhaustion throws without changing the input snapshot; there
is no resumable partial result. Machine macrosteps use the machine's
options.maxIterations bound, which is unbounded by default.
For running-actor tests, SimulatedClock.set/increment likewise visit each
deadline before reaching their destination, including new intermediate timers
and cancellations. A flush allows 10,000 callbacks. Clock advancement inside
a callback throws.
Large timer batches preserve deadline and insertion order without repeatedly
sorting the pending timers; cancelling a timer releases its callback immediately.