Skip to content
Version: XState v5

Migrating from XState v4 to v5

The guide below explains how to migrate from XState version 4 to version 5. Migrating from XState v4 to v5 should be a straightforward process. If you get stuck or have any questions, please reach out to the Stately team on our Discord.

This guide is for developers who want to update their codebase from v4 to v5 and should also be valuable for any developers wanting to know the differences between v4 and v5.

TypeScript​

XState v5 and its related libraries are written in TypeScript, and utilize complex types to provide the best type safety and inference possible for you. XState v5 requires TypeScript version 5.0 or greater. For best results, use the latest TypeScript version.

Follow these guidelines to ensure that your TypeScript project is ready to use XState v5:

  • Use the latest version of TypeScript, version 5.0 or greater (required)

    npm install typescript@latest --save-dev
  • Set strictNullChecks to true in your tsconfig.json file. This will ensure that our types work correctly and will also help catch errors in your code (strongly recommended)

    // tsconfig.json
    {
    "compilerOptions": {
    // ...
    "strictNullChecks": true
    // or set `strict` to true, which includes `strictNullChecks`
    // "strict": true
    }
    }
  • Set skipLibCheck to true in your tsconfig.json file (recommended)

Creating machines and actors​

Use createMachine(), not Machine()​

The Machine(config) function is now called createMachine(config):

import { createMachine } from 'xstate';

const machine = createMachine({
// ...
});

Use createActor(), not interpret()​

The interpret() function has been renamed to createActor():

import { createMachine, createActor } from 'xstate';

const machine = createMachine(/* ... */);

// βœ…
const actor = createActor(machine, {
// actor options
});

Use machine.provide(), not machine.withConfig()​

The machine.withConfig() method has been renamed to machine.provide():

// βœ…
const specificMachine = machine.provide({
actions: {
/* ... */
},
guards: {
/* ... */
},
actors: {
/* ... */
},
// ...
});

Set context with input, not machine.withContext()​

The machine.withContext(...) method can no longer be used, as context can no longer be overridden directly. Use input instead:

// βœ…
const machine = createMachine({
context: ({ input }) => ({
actualMoney: Math.min(input.money, 42),
}),
});

const actor = createActor(machine, {
input: {
money: 1000,
},
});

Actions ordered by default, predictableActionArguments no longer needed​

Actions are now in predictable order by default, so the predictableActionArguments flag is no longer required. Assign actions will always run in the order they are defined.

// βœ…
const machine = createMachine({
entry: [
({ context }) => {
console.log(context.count); // 0
},
assign({ count: 1 }),
({ context }) => {
console.log(context.count); // 1
},
assign({ count: 2 }),
({ context }) => {
console.log(context.count); // 2
},
],
});

States​

Use state.getMeta() instead of state.meta​

The state.meta property has been renamed to state.getMeta():

// βœ…
state.getMeta();

The state.getStrings() method has been removed​

Use state._nodes instead of state.configuration​

The state.configuration property has been renamed to state._nodes:

// βœ…
state._nodes;

Events and transitions​

Implementation functions receive a single argument​

Implementation functions now take in a single argument: an object with context, event, and other properties.

// βœ…
const machine = createMachine({
entry: ({ context, event }) => {
// ...
},
});

send() is removed; use raise() or sendTo()​

The send(...) action creator is removed. Use raise(...) for sending events to self or sendTo(...) for sending events to other actors instead.

Read the documentation on the sendTo action and raise action for more information.

// βœ…
const machine = createMachine({
// ...
entry: [
// Send an event to self
raise({ type: 'someEvent' }),

// Send an event to another actor
sendTo('someActor', { type: 'someEvent' }),
],
});

Pre-migration tip: Update v4 projects to use sendTo or raise instead of send.

Use enqueueActions() instead of pure() and choose()​

The pure() and choose() methods have been removed. Use enqueueActions() instead.

For pure() actions:

// βœ…
entry: [
enqueueActions(({ context, event, enqueue }) => {
enqueue('action1');
enqueue('action2');
})
];

For choose() actions:

// βœ…
entry: [
enqueueActions(({ enqueue, check }) => {
if (check('someGuard')) {
enqueue('action1');
enqueue('action2');
}
})
];

actor.send() no longer accepts string types​

String event types can no longer be sent to, e.g., actor.send(event); you must send an event object instead:

// βœ…
actor.send({ type: 'someEvent' });

Pre-migration tip: Update v4 projects to pass an object to .send().

state.can() no longer accepts string types​

String event types can no longer be sent to, e.g., state.can(event); you must send an event object instead:

// βœ…
state.can({ type: 'someEvent' });

Guarded transitions use guard, not cond​

The cond transition property for guarded transitions is now called guard:

// βœ…
const machine = createMachine({
on: {
someEvent: {
guard: 'someGuard',
target: 'someState',
},
},
});

Use params to pass params to actions & guards​

Properties other than type on action objects and guard objects should be nested under a params property; { type: 'someType', message: 'hello' } becomes { type: 'someType', params: { message: 'hello' }}. These params are then passed to the 2nd argument of the action or guard implementation:

// βœ…
const machine = createMachine({
entry: {
type: 'greet',
params: {
message: 'Hello world',
},
},
on: {
someEvent: {
guard: { type: 'isGreaterThan', params: { value: 42 } },
},
},
}).provide({
actions: {
greet: ({ context, event }, params) => {
console.log(params.message); // 'Hello world'
},
},
guards: {
isGreaterThan: ({ context, event }, params) => {
return event.value > params.value;
},
},
});

Pre-migration tip: Update action and guard objects on v4 projects to move properties (other than type) to a params object.

Use wildcard * transitions, not strict mode​

Strict mode is removed. If you want to throw on unhandled events, you should use a wildcard transition:

// βœ…
const machine = createMachine({
on: {
knownEvent: {
// ...
},
'*': {
// unknown event
actions: ({ event }) => {
throw new Error(`Unknown event: ${event.type}`);
},
},
},
});

Use explicit eventless (always) transitions​

Eventless (β€œalways”) transitions must now be defined through the always: { ... } property of a state node; they can no longer be defined via an empty string:

// βœ…
const machine = createMachine({
// ...
states: {
someState: {
always: {
target: 'anotherState',
},
},
},
});

Pre-migration tip: Update v4 projects to use always for eventless transitions.

Use reenter: true, not internal: false​

internal: false is now reenter: true

External transitions previously specified with internal: false are now specified with reenter: true:

// βœ…
const machine = createMachine({
// ...
on: {
someEvent: {
target: 'sameState',
reenter: true,
},
},
});

Transitions are internal by default, not external​

All transitions are implicitly internal. This change is relevant for transitions defined on compound state nodes with entry or exit actions, invoked actors, or delayed transitions (after). If you relied on implicit re-entering of a compound state node, use reenter: true:

// βœ…
const machine = createMachine({
// ...
states: {
compoundState: {
entry: 'someAction',
on: {
someEvent: {
target: 'compoundState.childState',
// Reenters the `compoundState` state,
// just like an external transition
reenter: true,
},
},
initial: 'childState',
states: {
childState: {},
},
},
},
});
// βœ…
const machine = createMachine({
// ...
states: {
compoundState: {
after: {
1000: {
target: 'compoundState.childState',
reenter: true, // make it external explicitly!
},
},
initial: 'childState',
states: {
childState: {},
},
},
},
});

Child state nodes are always re-entered​

Child state nodes are always re-entered when they are targeted by transitions (both external and internal) defined on compound state nodes. This change is relevant only if a child state node has entry or exit actions, invoked actors, or delayed transitions (after). Add a stateIn guard to prevent undesirable re-entry of the child state:

// βœ…

const machine = createMachine({
// ...
states: {
compoundState: {
on: {
someEvent: {
guard: not(stateIn({ compoundState: 'childState' })),
target: '.childState',
},
},
initial: 'childState',
states: {
childState: {
entry: 'someAction',
},
},
},
},
})

Use stateIn() to validate state transitions, not in​

The in: 'someState' transition property is removed. Use guard: stateIn(...) instead:

// βœ…
const machine = createMachine({
on: {
someEvent: {
guard: stateIn({ form: 'submitting' }),
target: 'someState',
},
},
});

Use actor.subscribe() instead of state.history​

The state.history property is removed. If you want the previous snapshot, you should maintain that via actor.subscribe(...) instead.

// βœ…
let previousSnapshot = actor.getSnapshot();

actor.subscribe((snapshot) => {
doSomeComparison(previousSnapshot, snapshot);
previousSnapshot = snapshot;
});

Pre-migration tip: Update v4 projects to track history using actor.subscribe().

Actions can throw errors without escalate​

The escalate action creator is removed. In XState v5 actions can throw errors, and they will propagate as expected. Errors can be handled using an onError transition.

// βœ…
const childMachine = createMachine({
// This will be sent to the parent machine that invokes this child
entry: () => {
throw new Error('This is some error')
}
});

const parentMachine = createMachine({
invoke: {
src: childMachine,
onError: {
actions: ({ context, event }) => {
console.log(event.error);
// {
// type: ...,
// error: {
// message: 'This is some error'
// }
// }
}
}
}
});

Actors​

Use actor logic creators for invoke.src instead of functions​

The available actor logic creators are:

  • createMachine
  • fromPromise
  • fromObservable
  • fromEventObservable
  • fromTransition
  • fromCallback

See Actors for more information.

// βœ…
import { fromPromise, createMachine } from 'xstate';

const machine = createMachine({
invoke: {
src: fromPromise(async ({ input }) => {
const data = await getData(input.userId);
// ...
return data;
}),
input: ({ context, event }) => ({
userId: context.userId,
}),
},
});
// βœ…
import { fromCallback, createMachine } from 'xstate';

const machine = createMachine({
invoke: {
src: fromCallback(({ sendBack, receive, input }) => {
// ...
}),
input: ({ context, event }) => ({
userId: context.userId,
}),
},
});
// βœ…
import { fromEventObservable, createMachine } from 'xstate';
import { interval, mapTo } from 'rxjs';

const machine = createMachine({
invoke: {
src: fromEventObservable(() =>
interval(1000).pipe(mapTo({ type: 'tick' })),
),
},
});

Use invoke.input instead of invoke.data​

The invoke.data property is removed. If you want to provide context to invoked actors, use invoke.input:

// βœ…
const someActor = createMachine({
// The input must be consumed by the invoked actor:
context: ({ input }) => input,
// ...
});

const machine = createMachine({
// ...
invoke: {
src: 'someActor',
input: {
value: 42,
},
},
});

Use output in final states instead of data​

To produce output data from a machine which reached its final state, use the top-level output property instead of data:

// βœ…
const machine = createMachine({
// ...
states: {
finished: {
type: 'final',
},
},
output: {
answer: 42,
},
});

To provide a dynamically generated output, replace invoke.data with invoke.output and add a top-level output property:

// βœ…
const machine = createMachine({
// ...
states: {
finished: {
type: 'final',
output: ({ event }) => ({
answer: event.someValue,
}),
},
},
output: ({ event }) => event.output,
});

Don't use property mappers in input or output​

If you want to provide dynamic context to invoked actors, or produce dynamic output from final states, use a function instead of an object with property mappers.

// βœ…
const machine = createMachine({
// ...
invoke: {
src: 'someActor',
input: ({ context, event }) => ({
value: event.value,
}),
},
});

// The input must be consumed by the invoked actor:
const someActor = createMachine({
// ...
context: ({ input }) => input,
});

// Producing machine output
const machine = createMachine({
// ...
states: {
finished: {
type: 'final',
},
},
output: ({ context, event }) => ({
answer: context.value,
}),
});

Use actors property on options object instead of services​

services have been renamed to actors:

// βœ…
const specificMachine = machine.provide({
actions: {
/* ... */
},
guards: {
/* ... */
},
actors: {
/* ... */
},
// ...
});

Use subscribe() for changes, not onTransition()​

The actor.onTransition(...) method is removed. Use actor.subscribe(...) instead.

// βœ…
const actor = createActor(machine);
actor.subscribe((state) => {
// ...
});

createActor() (formerly interpret()) accepts a second argument to restore state​

interpret(machine).start(state) is now createActor(machine, { snapshot }).start()

To restore an actor at a specific state, you should now pass the state as the snapshot property of the options argument of createActor(logic, options). The actor.start() property no longer takes in a state argument.

// βœ…
const actor = createActor(machine, { snapshot: someState });
actor.start();

Use actor.getSnapshot() to get actor’s state​

Subscribing to an actor (actor.subscribe(...)) after the actor has started will no longer emit the current snapshot immediately. Instead, read the current snapshot from actor.getSnapshot():

// βœ…
const actor = createActor(machine);
actor.start();

const initialState = actor.getSnapshot();

actor.subscribe((state) => {
// Snapshots from when the subscription was created
// Will not emit the current snapshot until a transition happens
});

Loop over events instead of using actor.batch()​

The actor.batch([...]) method for batching events is removed.

// βœ…
for (const event of events) {
actor.send(event);
}

Pre-migration tip: Update v4 projects to loop over events to send them as a batch.

Use snapshot.status === 'done' instead of snapshot.done​

The snapshot.done property, which was previously in the snapshot object of state machine actors, is removed. Use snapshot.status === 'done' instead, which is available to all actors:

// βœ…
const actor = createActor(machine);
actor.start();

actor.subscribe((snapshot) => {
if (snapshot.status === 'done') {
// ...
}
});

TypeScript​

Use types instead of schema​

The machineConfig.schema property is renamed to machineConfig.types:

// βœ…
const machine = createMachine({
types: {} as {
context: {
/* ...*/
};
events: {
/* ...*/
};
},
});

Use types.typegen instead of tsTypes​

The machineConfig.tsTypes property has been renamed and is now at machineConfig.types.typegen.

// βœ…
const machine = createMachine({
types: {} as {
typegen: {};
context: {
/* ...*/
};
events: {
/* ...*/
};
},
});

@xstate/react​

useInterpret() is now useActorRef()​

The useInterpret() hook, which is used to return an actorRef ("service" in XState v4), is renamed to useActorRef().

// βœ…
import { useActorRef } from '@xstate/react';

const actorRef = useActorRef(machine); // or any other logic

New features​

List coming soon

Frequently asked questions​

When will Stately Studio be compatible with XState v5?​

We are currently working on Stately Studio compatibility with XState v5. Exporting to XState v5 (JavaScript or TypeScript) is already available. We are working on support for new XState v5 features, such as higher-order guards, partial event wildcards, and machine input/output.

Upvote or comment on Stately Studio + XState v5 compatibility in our roadmap to stay updated on our progress.

When will the XState VS Code extension be compatible with XState v5?​

The XState VS Code extension is not yet compatible with XState v5. The extension is a priority for us, and work is already underway.

Upvote or comment on XState v5 compatibility for VS Code extension in our roadmap to stay updated on our progress.

When will XState v5 have typegen?​

TypeScript inference has been greatly improved in XState v5. Especially with features like the setup() API and dynamic parameters, the main use-cases for typegen are no longer needed.

However, we recognize that there may still be some specific use-cases for typegen. Upvote or comment on Typegen for XState v5 in our roadmap to stay updated on our progress.