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 "
useState
vs 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
xstate
and 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:
context
stores the inputtext
valuechange
event: updates the input valuesubmit
event: accessestext
from 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
+value
propssnapshot
+send
props
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
context
from the actor'ssnapshot
and passes it asvalue
onChange
usessend
to trigger thechange
event
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
snapshot
with the helper typeSnapshotFrom
, and thesend
type 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
ActorRefFrom
to type thecontext
value, andspawn
to 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
AnyActorRef
instead ofActorRefFrom
allows 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
value
from actor'scontext
usinguseSelector
- 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 thechange
event 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
change
events from multiple children (the eventtype
is 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
FormData
as part of thesubmit
event 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
xstate
and 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
context
from 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
.
children
specifies that an actor with the specifiedid
/src
combination 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
children
can beundefined
. That's because you can callinvoke
in any sub-state, therefore there is no guarantee that thechildren
is 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:
forwardTo
takes theid
of the child as parameter (defined insidechildren
,textActorId
in 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.