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.

Author Sandro Maglione

Sandro Maglione

Contact me

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:

You can view the full example repository on Github ๐Ÿ‘‡

patterns-for-state-management-with-actors-in-react-with-xstate

We 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 input text value
  • change event: updates the input value
  • submit event: accesses text 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 props
  • snapshot + 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's snapshot and passes it as value
  • onChange uses send to trigger the change 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 type SnapshotFrom, and the send type with ActorRefFrom.

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 the context value, and spawn 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 of ActorRefFrom 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's context using useSelector
  • 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:

  1. The state is duplicated in both child's context (value) and parent's context (text). It's also hard to debug if the change event is not sent to the parent for whatever reason.
  2. 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 event type 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:

  1. Receive FormData as part of the submit event and extract the state from it
  2. 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 specified id/src combination is required inside actors.

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 be undefined. That's because you can call invoke in any sub-state, therefore there is no guarantee that the children 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 the id of the child as parameter (defined inside children, 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.