Skip to content
— 17 minute read

Migrating machines to XState v5

Kevin Maes

We recently announced the release of XState v5! During its beta phase, we created a migration guide, specifically to call out breaking changes and to give developers onging updates regarding API changes. This post is a walkthrough of migrating existing XState machines from v4 to v5 and is intended to be more of a step-by-step companion to the migration guide. It also focuses on migrating XState machines that are using TypeScript.

We at Stately have dozens of XState machines in our own Stately Studio code base and have been migrating those to XState v5 as well. I’ve learned a lot about the migration process, especially after consulting my expert teammate, Mateusz, and I wanted to share some tips from my experience to make yours even smoother.

The following is a sequence of steps you can follow to migrate an existing XState v4 machine to XState v5. The order is merely a recommendation, not a requirement.

  1. Install XState v5 and dependencies
  2. Move types to setup() and remove typegen
  3. Convert action and guard strings to parameterized objects
  4. Include implementations or stubs in setup()
  5. Provide concrete implementations

1. Install XState v5 and dependencies​

The first step will be to install XState v5. If you’re using a framework or library, you can also install one of the integration packages:

Installing both XState v4 and v5 simultaneously​

If you have many state machines and would like to incrementally migrate those to v5, you can install both v4 and v5 side-by-side by following the migration steps listed here. Once you’ve completed those steps, you’ll end up with both dependencies in your package.json.

In order to install the integration packages, you’ll need to run a script.

Here’s an example with libraries pegged to the latest version at the time of this writing but you can also use npm:xstate@latest if you prefer.

// package.json after completing the steps in the migration guide
{
dependencies: {
xstate: '4.38.2',
xstate5: 'npm:xstate@5.6.0',
'@xstate/react': '3.2.2',
'@xstate/react5': 'npm:@xstate/react@4.0.3',
},
}

Importing from versioned packages​

If using a dual installation (v4 and v5 simultaneously), or even if only using v5, you will need to make sure you import the XState functions from the v5 packages in your code. At the top of your file, import functions like setup, assign, and other action creators from a xstatev5 package.

// In a v5 machines...
const machine = setup().createMachine({
context: {
prop: 'defaultValue',
},
on: {
next: {
// Make sure to import assign from the v5 package
// and not from v4!
actions: assign({ prop: 'value' }),
},
},
});

2. Move types to setup() and remove typegen​

One of the first migration steps, based on a breaking change, is to remove any TypeScript types from the schema property of the machine config from v4. These types should now be included under a new types property in the object passed to the new setup({}) function.

Passing types to createMachine({ schema: {} }) is still supported in v5, however, passing them to setup({}) is preferred since it automates implementations for actions (and more) to types.

import { createMachine } from 'xstate';

const machine = createMachine({
...
tsTypes: {} as import('./myMachine.typegen').Typegen0,
schema: {
context: {} as {
prop1: string;
prop2: number;
},
events: {} as
| {
type: 'next';
value: number;
} | {
type: 'back';
value: number;
};
},
services: {} as {
fetchUserDetails: {
data: { email: string, name: string };
};
},
}
});

When defining types, you may have noticed in past examples that an empty object is often cast as the types you want for each property like events and actions.

const machine = setup({
types: {
context: {} as {
prop1: string;
prop2: number;
};
events: {} as { type: 'next' } | { type: 'next' };
},
})

That still works but but it’s even easier to just cast the whole types object all at once with types: {} as {}.

const machine = setup({
types: {} as {
context: {
prop1: string;
prop2: number;
};
events: { type: 'next' } | { type: 'next' };
},
});

Remove typegen​

Typegen is no longer supported in XState v5 so you can remove tsTypes from your machine config. Without typegen, typing events in actions and guards must be done manually using type narrowing in implementation functions. However, the following sections will show you how to type a new params argument passed to those implementations and skip manual type narrowing.

3. Convert action and guard strings to parameterized objects​

We can convert action and guard strings in the machine config to parameterized objects. This is particularly helpful for actions and guards on transitions if they require use of event props. This allows you to explicitly map event properties to a params object for your implementation functions so that they're automatically typed and there's no need for additional type narrowing.

On the other hand, you can continue to use reference string named entry and exit actions or guards if they only make use of context values (already typed). Even inline functions can be used for these in the config.

For example, these events are still perfectly fine in XState v5 as they were in v4:

import { setup } from 'xstate';

const machine = setup({
types: {} as {
context: {
prop1: string;
};
}
} })
.createMachine({
states: {
first: {
entry: 'track',
exit: ({ context }) => {
console.log(context.prop1, 'is already typed');
},
},
},
});

For transition events and guards, we can convert named action or guard strings to action objects which allows us to define an explicit params object that will be received at runtime by our implementation functions. There are two ways to do this:

  1. We can define params as a static value
  2. Use dynamic action parameters, a function that receives context and/or event, allowing you to map those values to the params object.
import { createMachine } from 'xstate';

const machine = createMachine({
on: {
next: {
actions: ['track'],
},
back: {
actions: ['track'],
},
},
});

This may appear a bit more verbose in v5 but it will allow us to skip manual type narrowing in our implementation functions. The params object will be automatically typed based on the event properties we map to it.

Here is a more complete example of converting action strings to parameterized actions:

import { setup } from 'xstate';

const machine = setup({
types: {} as {
events: {
type: 'next';
prop1: string;
prop2: number;
prop3: boolean;
};
},
/* more setup */
}).createMachine({
on: {
next: {
target: 'first',
actions: [
{
guard: {
type: 'is this ready',
params: ({ event }) => ({ ready: event.ready }),
},
type: 'doThis',
// Later, the action implementation function will be
// passed this string value in the 2nd arg, params.
params: ({ event }) => ({ prop1: event.prop1 }),
},
{
type: 'doThat',
// Later, the action implementation function will be
// passed this number value in the 2nd arg, params.
params: ({ event }) => ({ prop2: event.prop2 }),
},
],
},
},
states: {
first: {
entry: {
type: 'whenEntering',
// Later, the action implementation function will be
// passed only these 2 values as the 2nd arg, params.
params: ({ event }) => ({
prop1: event.prop1,
prop2: event.prop2,
}),
},
exit: {
type: 'whenExiting',
// Later, the action implementation function will be
// passed only these 2 values as the 2nd arg, params.
params: ({ event }) => ({
prop2: event.prop2,
prop3: event.prop3,
}),
},
},
},
});

4. Include implementations or stubs in setup()​

We must provide implementations for actions, guards, and actors in the object passed to setup(). If the machine has everything it needs to carry out these implementations on its own then these will be the actual implementations. However, if the machine needs to reference dependencies from the outside world, then these serve as stubs to be overridden later. Passing a combination of stubs and concrete implementations to setup() is perfectly fine.

Stubbing actions​

setup({
actions: {
doThis: (_, params: { prop: string }) => {
// You can include a concrete implementation here
console.log(params.prop);
},
// Stubbed implementation
doThat: (_, params: { prop: number }) => {},
whenEntering: (_, params: { prop1: string; prop2: number }) => {
// You can include a concrete implementation here
console.log(prop1, prop2);
},
// Stubbed implementation
whenExiting: (_, params: { prop2: number; prop3: boolean }) => {},
},
});

Any assign actions, passed to setup(), shouldn’t require anything external to the machine config since they set values based on context or typed params.

Stubbing guards​

setup({
guards: {
'is this ready': (_, params: { ready: boolean }) => {
// Concrete implementation here
return ready;
},
// Stubbed implementation
'are we there yet': (_, params: { distance: number }) => false,
},
});

Stubbing actors​

Invoked actors can be stubbed with the actual actor creator helper function that will be used in the real implementation. The main purpose here is to type the input and output of the actor. There are two ways to do this:

This first way is more transferrable across other types of logic creators.

setup({
actors: {
doSomethingAsync: fromPromise(
async (_: {
input: {
inputProp1: string;
inputProp2: number;
};
}): Promise<Item[]> => {
throw new Error('Not implemented');
},
),
},
});

The second way is a bit shorter and more specific to fromPromise.

setup({
actors: {
doSomethingAsync: fromPromise<
// Promise-wrapped output
Item[],
// input
{
inputProp1: string;
inputProp2: number;
}
>(async () => {
throw new Error('Not implemented');
}),
},
});

Reducing external dependencies​

Even dependencies that are external to the machine can be made available to the machine using one of the following methods:

Injecting external dependencies with input​

If dependencies are not expected to change throughout the lifetime of the machine, then you can pass them as input to setup() and they will become available from within the machine.

setup({
input: {
externalDependency1: someRef,
externalDependency2: anotherRef,
},
});

Injecting external dependencies by sending an event​

If dependencies are expected to change over time, then you can send these updates as events to the machine. For example, an event containing a ref to a dependency can be stored in context for use by the machine.

send({
type: 'refs.inject',
externalDependency,
});

However, in some cases it’s just not feasible or convenient to inject dependencies. There may be too many dependencies or you might wish to avoid tightly coupling them to the machine. The next section describes how to provide concrete implementations to override the stubbed implementations, on a per-use basis throughout your application.

5. Provide concrete implementations​

Stately Studio is a NextJS application so we are using the @xstate/react package in our React components. We can provide concrete implementations to our stubs by using the useActorRef() hook. This hook allows us to pass in a machine and receive an actor ref that we can use to send events to that machine. We can provide dependencies to the machine such as our concrete implementations.

import { useActorRef } from '@xstate/react';

const actorRef = useActorRef(
machine.provide({
actions: {
doThat: (_, prop2) => {
// Concrete implementation here
console.log(prop2);
},
whenExiting: (_, params) => {
// Concrete implementation here
console.log(params.prop2, params.prop3);
},
},
}),
);

In other components we may be using a context provider to give access to an actorRef at various levels in the component tree. A machine context provider can be created using a machine:

import { createMachine } from './machine';
import { createActorContext } from '@xstate/react';

const machine = setup({
/* setup config */
}).createMachine({
/* machine config */
});

export const MachineContext = createActorContext(machine);

It can then be imported and used in a component tree:

import { MachineContext } from './machine';

function App() {
return (
<MachineContext.Provider
logic={machine.provide({
actions: {
doThat: (_, prop2) => {
// Concrete implementation here
console.log(prop2);
},
whenExiting: (_, params) => {
// Concrete implementation here
console.log(params.prop2, params.prop3);
},
},
})}
>
{children}
</MachineContext.Provider>
);
}

In this case, the provider is passed a logic prop whose value is the machine with the provided implementations. All the way down the component tree, we can use the useActorRef() hook to access the actorRef and send events to the machine.

Providing actor implementations​

The following 3 things must be defined:

  1. Provide either a concrete implementation or a stub for the actor creator function in setup()
  2. Register the invoke within a state in the main machine config.
  3. Provide a concrete implementation for the actor if not already passing it to setup().

Registering the invoke in a state in the main machine config​

This is similar to how invoked actors were registered in v4. The main difference is we also define the input here where we map event values to input values. The actions registered with onDone and onError are also defined as objects with params just like we saw before with transition actions and entry/exit actions.

import { createMachine } from 'xstate';

createMachine({
/* machine config */
states: {
/* other states */
someState: {
invoke: {
src: 'doSomethingAsync', // required
id: 'doSomethingAsync', // optional
input: ({ event }) => ({
inputProp1: event.prop1,
inputProp2: event.prop2,
}),
onDone: {
target: 'Idle',
actions: [
{
type: 'showSuccessToast',
},
{
type: 'handleOutputOnSuccess',
params: ({ event }) => event.output,
},
],
},
onError: {
target: 'Idle',
actions: [{ type: 'showErrorToast' }],
},
},
},
},
});

Providing a concrete implementation for the actor if not already passing it to setup()​

If we only defined a stub for the actor creator function in setup() then we must provide a concrete implementation for the actor with our other concrete implementations.

import { useActorRef } from '@xstate/react';

const actorRef = useActorRef(
machine.provide({
actors: {
doSomethingAsync: fromPromise(
({
input,
}: {
input: {
inputProp1: string;
inputProp2: number;
};
}) => {
return trpcProxyClient.stuff.asyncstuff.mutate(input);
},
),
},
}),
);

Housekeeping and troubleshooting​

Remove preserveActionOrder and predictableActionArguments​

You can now remove preserveActionOrder and predictableActionArguments from your machine config as they are no longer needed in XState v5. Actions are now in predictable order by default and assign actions will always run in the order they are defined.

// ❌ DEPRECATED
import { createMachine } from 'xstate';

const machine = createMachine({
preserveActionOrder: true,
predictableActionArguments: true,
...
});

Troubleshooting TypeScript errors​

You may likely see many TypeScript errors along the way so don’t get discouraged. For example, in order to fully migrate any one action in your machine, you’ll likely need to convert it to a parameterized object in the machine config and provide a typed implementation to setup() in order to fix TypeScript errors.

Furthermore, TypeScript could continue complaining about the actions implementations passed to setup() until the very last action has been included there and typed properly. This is because TypeScript is checking the entire machine config and all of its implementations at once.

TypeScript may have trouble pinpointing the exact location of the error in the machine config so it will often highlight the state name instead.

Misleading typescript error highlighting a state when its internal action type is the problem

In the example, above, the real source of the error is that the trackUpgradeModalLearnMoreClick action has not yet been converted to an action object. Fix that and the error under the "Click to learn more" state name goes away.

Summary​

By following the sequence of steps above, you should be able to migrate your existing XState v4 machines to XState v5 and get all of the types working with little to no need for type narrowing via assertions or type guards.

I hope this walkthrough has been helpful in migrating your existing XState v4 machines to XState v5. If you have any questions or feedback, please reach out to us on our Discord.