TLDR
If you are curious about the full-working example, it is here.
Background
I've been using xState with React for some time already. Throughout the projects' development, I often find myself reusing the same machine in different contexts. Sometimes makes sense to interpret
the machine from the React component, other times it is the logical choice to spawn
and store it as a child actor
.
That's why I came up with a simple abstraction that let's me use the machine in both ways.
Use case example
Let's say we need a toggle machine for our checkbox component. We create a simple machine with on
and off
states and then use it directly in our checkbox component via the useMachine
hook.
But in another page we have a more complex machine, which can make good use of the toggle machine for displaying a notification based on business logic. This sounds as a good opportunity to spawn
a toggle actor, which will be controlled by the complex machine.
Now we need the same machine, but utilised in two different ways.
Checkbox component
Firstly, we need to create our toggle machine.
export const toggleMachine = createMachine({
id: "toggleMachine",
schema: {
events: {} as { type: "TOGGLE" },
},
initial: "off",
states: {
off: {
on: {
TOGGLE: {
target: "on",
},
},
},
on: {
on: {
TOGGLE: {
target: "off",
},
},
},
},
});
It is a simple statechart
with on
and off
states. This will be sufficient for our checkbox component. We just have to pass it to the useMachine
hook.
interface Props {
onChange(checked: boolean): void;
}
export function Checkbox({ onChange }: Props) {
const [state, send] = useMachine(toggleMachine);
return (
<>
<label htmlFor="toggle">Toggle</label>
<input
id="toggle"
type="checkbox"
checked={state.matches("on")}
onChange={(event) => {
send({ type: "TOGGLE" });
onChange(event.target.checked);
}}
/>
</>
);
}
We use the matches
method to confirm the machine state and control the checked
attribute. When onChange
is triggered, the machine state is toggled and the callback from the props is fired.
The only problem that we are facing now is that we cannot control the initial state of the checkbox component. It will always be initialised as unchecked
until further interaction occurs.
We can easily solve the issue by lazy-loading the machine. By returning the machine from a factory function, we can pass the initial state as a variable.
The final step, is to lazily-create the machine with the useMachine
hook. This will prevent the warnings for a new machine instance being passed to the hook on each re-render.
export function toggleMachine({ initial }: { initial: string }) {
return createMachine({
id: "toggleMachine",
initial,
states: {
/* ... */
},
});
}
/* ... */
const [state, send] = useMachine(
() => toggleMachine({ initial })
);
Data fetching notification
Supposing we need to show specific data to the user when downloaded. To make it sure that the user won't miss the information, we put it in a notification component.
We can start by creating the fetching machine (parent machine) that takes care of downloading the user info.
const fetchingMachine = createMachine(
{
id: "fetchMachine",
initial: "fetching",
on: {
FETCHING: {
target: "fetching",
},
},
states: {
idle: {},
fetching: {
invoke: {
src: "fetchData",
onDone: {
target: "idle",
},
},
},
},
},
{
services: {
async fetchData() {
const response = await fetch(
"https://jsonplaceholder.typicode.com/todos/"
);
const data = await response.json();
return data;
},
},
}
);
Similarly to the Checkbox
component, the Notificaion
one needs on
and off
states, in order to control its visibility. The difference now is that instead of interpreting the machine directly in the component, we can spawn
an actor
and keep it in the fetching machine
's context.
In my opinion, this approach gives us more flexibility when it comes to controlling the toggle machine. We can spawn
the machine explicitly when needed, instead of relying on the React render cycle.
const fetchingMachine = createMachine(
{
/* ... */
states: {
idle: {},
fetching: {
invoke: {
src: "fetchData",
onDone: {
actions: ["assignToggleRef"],
target: "idle",
},
},
},
},
},
{
actions: {
assignToggleRef: assign({
toggleRef: (context, event) => {
return spawn(toggleMachine({ initial: "on" }));
},
}),
},
services: {
async fetchData() {
/* ... */
},
},
}
);
We can simply pass the toggleRef
to the Notification component
and interpret it from there with the guarantee that the fetching of the data is completely finished.
interface Props {
actor: ActorRefFrom<typeof toggleMachine>;
}
export function Notification({ actor, data }: Props) {
const [state, send] = useActor(actor);
return (
<div>
{state.matches("on") && <div>Very important notification</div>}
<button
onClick={() => {
send({ type: "TOGGLE" });
}}
>
{state.matches("on") ? "close" : "open"}
</button>
</div>
);
}
Now we have access to the actor's state
and context
. Also, since the actor reference is kept in the parent's machine context, we can send
events to the interpreted actor regardless of the parent's machine state.
Unite both worlds
Using directly the exported machine is perfectly fine, but I fancy adding one more layer of abstraction.
import { ActorRefFrom, createMachine, spawn } from "xstate";
export type ToggleMachineActor = ActorRefFrom<typeof toggleMachine>;
function toggleMachine({ initial }: { initial: string }) {
return createMachine({
id: "toggleMachine",
/* ... */
});
}
export function toggleMachineCreate({ initial = "off" }): {
machine: ReturnType<typeof toggleMachine>;
spawn: () => ToggleMachineActor;
} {
const machine = toggleMachine({ initial });
return {
machine,
spawn: () => spawn(machine, { name: "toggleMachine" }),
};
}
Now, our toggleMachineCreate
returns both the machine instance and the spawned actor, which I find a bit cleaner when it comes to using the machine.
toggleRef: (context, event) => {
return toggleMachineCreate({ initial: "on" }).spawn();
}
/* or */
const [state, send] = useMachine(
() => toggleMachineCreate({ initial }).machine
);
Another advantage of this approach is that we have a convenient place where we can extend the machines with the withContext
and withConfig
utility methods. This gives our code а pinch of readability.
export function toggleMachineCreate({ initial = "off", specialService = Promise<any> }): {
machine: ReturnType<typeof toggleMachine>;
spawn: () => ToggleMachineActor;
} {
const machine = toggleMachine({ initial }).withConfig({
services: { specialService },
});
return {
machine,
spawn: () => spawn(machine, { name: "toggleMachine" }),
};
}
On top of that, you can easily pass arguments to the spawn
function too. That might be helpful when multiple actors from a machine are spawned and stored into a single context. Distinguishing the references by name, might be helpful when it comes to communication with them.
export function toggleMachineCreate(): {
/* ... */
return {
machine,
spawn: (name: string) => spawn(machine, { name }),
};
}
/* ... */
actions: {
assignToggleRef1: assign({
toggleRef1: (context, event) => {
return toggleMachineCreate().spawn("toggleMachine1");
},
}),
assignToggleRef2: assign({
toggleRef2: (context, event) => {
return toggleMachineCreate().spawn("toggleMachine2");
},
}),
},
/* ... */
And last but not least, I find it really handy to have the actor type exported along with the toggleMachineCreate
function. It is quite useful when it comes to prop drilling the actor and defining the prop types.
export type ToggleMachineActor = ActorRefFrom<typeof toggleMachine>;
/* ... */
interface Props {
actor: ToggleMachineActor;
}
Opinionated conclusion
This pattern scales greatly and gives a good amount of predictability in regards to code organisation and readability.
Top comments (0)