Patterns for state management with actors in React with XState
Actors are ideal for state management, and XState has all you need to implement them in React. Here are all the ways you can create and combine actors in React with XState.

Sandro Maglione
Contact me1297 words
ใปActors make state management composable, reusable, and maintainable.
xstate implements actors, but few people use them to their full power.
In practice, there are many ways to implement and compose actors with xstate. This article is an overview of all the patterns that you can use, with their pros and cons:
- All the logic inside a single actor
- Child sending events to parent reference
- Parent with reference to child inside context
- Parent invokes child
- Send to parent, forward to child
You can view the full example repository on Github ๐
patterns-for-state-management-with-actors-in-react-with-xstateWe are going to implement the following example with actors and xstate:
This article is not about "
useStatevs actors" ๐I assume you already have an interest in actors, you have a sense on what problems they solve, and you want to put it in practice.
Take a look at XState: Complete Getting Started Guide to learn about using
xstateand its benefits.
function App() {
const [text, setText] = useState("");
return (
<form action={() => console.log({ text })}>
<input
type="text"
value={text}
onChange={(e) => setText(e.target.value)}
/>
<button type="submit">Submit</button>
</form>
);
}Let's see how this logic can be implemented using xstate and actors!
All logic inside parent actor
The most simple choice is to have one single actor that implements the full <form> + <input> logic:
contextstores the inputtextvaluechangeevent: updates the input valuesubmitevent: accessestextfrom context
export const actorWithValue = setup({
types: {
// Input value stored into context
context: {} as { text: string },
// Events to change input and submit form
events: {} as { type: "change"; value: string } | { type: "submit" },
},
}).createMachine({
context: { text: "" },
on: {
change: {
actions: assign(({ event }) => ({ text: event.value })),
},
submit: {
actions: ({ context }) => {
console.log({ text: context.text });
},
},
},
});There are two ways to use this actor in your React components:
onChange+valuepropssnapshot+sendprops
Passing onChange and value props
This strategy looks like "normal" actor-less React.
An InputText component gets an onChange and value props:
const InputText = ({
value,
onChange,
}: {
value: string;
onChange: (value: string) => void;
}) => {
return (
<input
type="text"
value={value}
onChange={(e) => onChange(e.target.value)}
/>
);
};The form component creates an instance of the actor with useActor:
- Extract the
contextfrom the actor'ssnapshotand passes it asvalue onChangeusessendto trigger thechangeevent
function App() {
const [snapshot, send] = useActor(FormActor.actorWithValue);
return (
<form action={() => send({ type: "submit" })}>
<InputText
value={snapshot.context.text}
onChange={(value) => send({ type: "change", value })}
/>
<button type="submit">Submit</button>
</form>
);
}Passing send and snapshot as props
The other option is to provide the full snapshot and send to the input component:
Extract the actor
snapshotwith the helper typeSnapshotFrom, and thesendtype withActorRefFrom.
import type { ActorRefFrom, SnapshotFrom } from "xstate";
const InputTextSharedMachine = ({
send,
snapshot,
}: {
snapshot: SnapshotFrom<typeof FormActor.actorWithValue>;
send: ActorRefFrom<typeof FormActor.actorWithValue>["send"];
}) => {
return (
<input
type="text"
value={snapshot.context.text}
onChange={(e) => send({ type: "change", value: e.target.value })}
/>
);
};function App() {
const [snapshot, send] = useActor(FormActor.actorWithValue);
return (
<form action={() => send({ type: "submit" })}>
<InputTextSharedMachine send={send} snapshot={snapshot} />
<button type="submit">Submit</button>
</form>
);
}This strategy is more convenient when the sub-component needs to access multiple context values and events.
Instead of adding multiple props (onChange, onInput, onSubmit) you provide the full send function.
A single actor is not ideal.
The downside of this approach is that the logic is mixed and not reusable.
As you add more and more fields to the form, context will grow with more and more values, more and more events (change.name, change.email, change.password), and this single actor will become more and more complex.
Alas, I do not recommend this strategy (it doesn't use the power of actors at all ๐๐ผโโ๏ธ).
Instead, the aim is to split the <form> logic from the <input> logic as two separate actors:
- Form actor: allows to submit and log the
<input>value - Input actor: stores and updates the
<input>value
The rest of the article is all about how to combine these two actors to work together.
Child sending events to parent reference
When implementing two actors, we need a way for the parent actor (<form>) to reference/communicate with the child actor (<input>).
An option is to spawn the child actor and store its reference inside the context of the parent:
Use
ActorRefFromto type thecontextvalue, andspawnto create the child actor instance.
export const actorSendTo = setup({
types: {
context: {} as {
text: string;
textActor: ActorRefFrom<typeof InputTextActor.actorSendTo>;
},
events: {} as { type: "submit" } | { type: "change"; value: string },
},
}).createMachine({
context: ({ spawn, self }) => ({
text: "",
textActor: spawn(InputTextActor.actorSendTo, {
input: { parentRef: self },
}),
}),
on: {
change: {
actions: assign(({ event }) => ({ text: event.value })),
},
submit: {
actions: ({ context }) => {
console.log({ text: context.text });
},
},
},
});The child actor stores a generic parent reference (AnyActorRef) in its context.
Using
AnyActorRefinstead ofActorRefFromallows the child actor to be used with any parent, not just a specific actor/machine.
On each change event, the actor uses sendTo to notify the parent. The parent will then update its own text value:
export const actorSendTo = setup({
types: {
input: {} as { parentRef: AnyActorRef },
context: {} as {
parentRef: AnyActorRef;
value: string;
},
events: {} as { type: "change"; value: string },
},
}).createMachine({
context: ({ input }: { input: { parentRef: AnyActorRef } }) => ({
parentRef: input.parentRef,
value: "",
}),
on: {
change: {
actions: [
assign(({ event }) => ({ value: event.value })),
sendTo(
({ context }) => context.parentRef,
({ event }) => ({
type: "change",
value: event.value,
})
),
],
},
},
});The <input> React component receives an instance of the child actor as prop (ActorRefFrom):
- Extract the
valuefrom actor'scontextusinguseSelector - Send events directly using
actor
const InputTextSendTo = ({
actor,
}: {
actor: ActorRefFrom<typeof InputTextActor.actorSendTo>;
}) => {
const value = useSelector(actor, (snapshot) => snapshot.context.value);
return (
<input
type="text"
value={value}
onChange={(e) => actor.send({ type: "change", value: e.target.value })}
/>
);
};The form component only needs to provide a single actor prop, extracted from the actor context:
function App() {
const [snapshot, send] = useActor(FormActor.actorSendTo);
return (
<form action={() => send({ type: "submit" })}>
<InputTextSendTo actor={snapshot.context.textActor} />
<button type="submit">Submit</button>
</form>
);
}The advantage is having the
<input>logic completely separate from the<form>logic.
If you need to add more features to the <input> (suggestions, validation, show/hide password), you work only on the child actor.
The parent stays the same. It only cares about getting the final value from the change event.
But, there are two major issues with this approach:
- The state is duplicated in both child's context (
value) and parent's context (text). It's also hard to debug if thechangeevent is not sent to the parent for whatever reason. - You cannot spawn multiple copies of the same child actor inside the parent. That's because the parent cannot distinguish
changeevents from multiple children (the eventtypeis the same for all of them ๐๐ผโโ๏ธ)
This strategy is mostly useful for one-off child actors (not that frequent, not reusable ๐คฆ).
Parent with reference to child inside context
Let's solve the problems above by getting rid of the duplicated state.
We just keep the reference to the child actor like before using ActorRefFrom inside context, and remove the change event altogether.
Instead of duplicating the state, the parent has two options:
- Receive
FormDataas part of thesubmitevent and extract the state from it - Access the state of the child actor directly from its snapshot (
getSnapshot)
export const actorWithRef = setup({
types: {
context: {} as {
// ๐๐ผโโ๏ธ No more duplicated `text` value in context
textActor: ActorRefFrom<typeof InputTextActor.actorIndependent>;
},
events: {} as {
type: "submit";
formData: FormData;
},
},
}).createMachine({
context: ({ spawn }) => ({
textActor: spawn(InputTextActor.actorIndependent),
}),
on: {
submit: {
actions: ({ event, context }) => {
console.log({ text: event.formData.get("text") });
console.log({ text: context.textActor.getSnapshot().context.value });
},
},
},
});The child actor is also way more simple. No more parent references nor sendTo:
The child actor now is completely independent. It doesn't care about any other actor or where it's used ๐
export const actorIndependent = setup({
types: {
context: {} as { value: string },
events: {} as { type: "change"; value: string },
},
}).createMachine({
context: { value: "" },
on: {
change: { actions: assign(({ event }) => ({ value: event.value })) },
},
});The component code doesn't change at all:
That's a key advantage of
xstateand actors: the logic is completely separate from components.Changes to actors (mostly) won't break your components ๐
const InputTextWithActor = ({
name,
actor,
}: {
name: string; // ๐ Added `name` reference for `FormData`
actor: ActorRefFrom<typeof InputTextActor.actorIndependent>;
}) => {
const value = useSelector(actor, (snapshot) => snapshot.context.value);
return (
<input
type="text"
name={name}
value={value}
onChange={(e) => actor.send({ type: "change", value: e.target.value })}
/>
);
};function App() {
const [snapshot, send] = useActor(FormActor.actorWithRef);
return (
<form action={(formData) => send({ type: "submit", formData })}>
<InputTextWithActor name="text" actor={snapshot.context.textActor} />
<button type="submit">Submit</button>
</form>
);
}This approach works great, but it doesn't allow parent-child events.
It works if the parent only needs to access the child context, but not when the child needs to send notifications or more complex events to the parent.
In most practical cases, accessing
contextfrom the parent is enough. I use this pattern quite often ๐
Parent invokes child
Another option is to skip spawn and the parent's context, and rely on invoke instead.
The parent doesn't need context anymore, but instead we define children.
childrenspecifies that an actor with the specifiedid/srccombination is required insideactors.
We then add invoke in the root of createMachine.
Since the parent doesn't store any reference to the child anymore, we need to add FormData to submit to access the input value:
export const actorInvoke = setup({
types: {
events: {} as {
type: "submit";
formData: FormData;
},
children: {} as {
textActorId: "textActorSrc";
},
},
actors: { textActorSrc: InputTextActor.actorIndependent },
}).createMachine({
invoke: {
id: "textActorId",
src: "textActorSrc",
},
on: {
submit: {
actions: ({ event }) => {
console.log({
text: event.formData.get("text"),
});
},
},
},
});The child actor was already independent from the parent, so nothing changes:
export const actorIndependent = setup({
types: {
context: {} as { value: string },
events: {} as { type: "change"; value: string },
},
}).createMachine({
context: { value: "" },
on: {
change: { actions: assign(({ event }) => ({ value: event.value })) },
},
});const InputTextInvoke = ({
name,
actor,
}: {
name: string;
actor: ActorRefFrom<typeof InputTextActor.actorIndependent>;
}) => {
const value = useSelector(actor, (snapshot) => snapshot.context.value);
return (
<input
type="text"
name={name}
value={value}
onChange={(e) => actor.send({ type: "change", value: e.target.value })}
/>
);
};The major difference is how you provide the child actor to the sub-component.
The child actor instance is no more stored inside context, but instead inside children:
Actors inside
childrencan beundefined. That's because you can callinvokein any sub-state, therefore there is no guarantee that thechildrenis running.That's not a problem here since we invoke the actor in the root, therefore it will be available as long as the parent is running.
function App() {
const [snapshot, send] = useActor(FormActor.actorInvoke);
return (
<form action={(formData) => send({ type: "submit", formData })}>
{snapshot.children.textActorId && (
<InputTextInvoke name="text" actor={snapshot.children.textActorId} />
)}
<button type="submit">Submit</button>
</form>
);
}This strategy removes the need of context, but it's mostly the same as before, and it requires FormData.
Send to parent, forward to child
What if you still want to send events between actors, but avoid manual references?
Instead of using sendTo and having to store a reference, you can use sendParent (for child-to-parent events) and forwardTo (for parent-to-child events):
The parent is the actor who invoked the child. The child has no knowledge of it.
export const actorSendParent = setup({
types: {
context: {} as { value: string },
events: {} as { type: "change"; value: string },
},
}).createMachine({
context: { value: "" },
on: {
change: {
actions: [
assign(({ event }) => ({ value: event.value })),
// ๐ Push event to the parent
sendParent(({ event }) => ({
type: "change",
value: event.value,
})),
],
},
},
});The parent invokes the child actor and it will receive the change event from the child:
export const actorSendParent = setup({
types: {
context: {} as { text: string },
events: {} as { type: "change"; value: string },
children: {} as {
textActorId: "textActorSrc";
},
},
actors: { textActorSrc: InputTextActor.actorSendParent },
}).createMachine({
context: { text: "" },
// ๐ Invoke child actor inside parent
invoke: { id: "textActorId", src: "textActorSrc" },
on: {
// ๐ `change` event comes from the child
change: {
actions: assign(({ event }) => ({ text: event.value })),
},
},
});The parent can also send events to the child by using forwardTo. In this example, we add a submit event that the parent forwards to the child:
forwardTotakes theidof the child as parameter (defined insidechildren,textActorIdin the example).
export const actorSendParent = setup({
types: {
context: {} as { text: string },
events: {} as
| { type: "submit" }
| { type: "change"; value: string },
children: {} as {
textActorId: "textActorSrc";
},
},
actors: { textActorSrc: InputTextActor.actorSendParent },
}).createMachine({
context: { text: "" },
invoke: { id: "textActorId", src: "textActorSrc" },
on: {
change: {
actions: assign(({ event }) => ({ text: event.value })),
},
submit: {
actions: [
({ context }) => {
console.log({ text: context.text });
},
forwardTo("textActorId"),
],
},
},
});We add the submit event sent by the parent also to the child, and we use it to reset its value:
export const actorSendParent = setup({
types: {
context: {} as { value: string },
events: {} as
| { type: "change"; value: string }
| { type: "submit" },
},
}).createMachine({
context: { value: "" },
on: {
submit: {
// ๐ Reset value on `submit` from the parent
actions: assign({ value: "" }),
},
change: {
actions: [
assign(({ event }) => ({ value: event.value })),
sendParent(({ event }) => ({
type: "change",
value: event.value,
})),
],
},
},
});The components remain the same as the previous examples. We extract the invoked child and pass it down to the <input> component:
const InputTextSendParent = ({
actor,
}: {
actor: ActorRefFrom<typeof InputTextActor.actorSendParent>;
}) => {
const value = useSelector(actor, (snapshot) => snapshot.context.value);
return (
<input
type="text"
value={value}
onChange={(e) => actor.send({ type: "change", value: e.target.value })}
/>
);
};function App() {
const [snapshot, send] = useActor(FormActor.actorSendParent);
return (
<form action={() => send({ type: "submit" })}>
{snapshot.children.textActorId && (
<InputTextSendParent
actor={snapshot.children.textActorId}
/>
)}
<button type="submit">Submit</button>
</form>
);
}Using sendParent and forwardTo avoids any direct reference between parent and child.
The main problem is that sendParent requires the child actor to be nested inside another actor, it cannot be used in isolation anymore (otherwise sendParent will fail).
This restrictions makes the actor less reusable and tight to certain contexts.