Do not use callback props in React with XState

When a custom component starts collecting onX props, it's a strong signal that you are doing something wrong. You should model the component as an actor instead. Here is how to do it.


You see onSubmit, onChange, onSelect, and friends everywhere in React component props.

But with XState, those props passed into custom components are often a clue that you are doing something wrong.

The parent component is doing too much work. The child logic is leaking into the parent.

That usually means there is a missing actor πŸ”Ž

Callback props don't belong to XState

Start with a parent component using a machine:

function SearchPage() {
  const [snapshot, send] = useMachine(searchPageMachine);

  return (
    <SearchForm
      value={snapshot.context.query}
      onChange={(query) => {
        send({ type: "query.changed", query });
      }}
      onSubmit={() => {
        send({ type: "search.submitted" });
      }}
    />
  );
}

And the child component:

type Props = {
  onChange: (query: string) => void;
  onSubmit: () => void;
  value: string;
};

function SearchForm({ value, onChange, onSubmit }: Props) {
  return (
    <form
      onSubmit={(event) => {
        event.preventDefault();
        onSubmit();
      }}
    >
      <input
        value={value}
        onChange={(event) => {
          onChange(event.currentTarget.value);
        }}
      />
    </form>
  );
}

This works in normal React. But it will become a problem with XState and actors.

The parent machine implements the logic of the child component.

This will become a huge problem (and a huge machine) if this is done for all child components 😬

The parent is forced to know too much about the child’s interaction model.

SearchForm has its own behavior and states, but instead of modeling that behavior directly, the parent is mixing multiple concerns in a single huge machine.

That creates a few problems:

  • The child behavior is not reusable, but mixed into a bigger machine
  • The parent keeps growing with more and more concerns
  • The prop API grows as the child gets more interactive: onChange, onSubmit, onClear, onFocus, onRetry.
  • The event model is split between React props and XState events.

The callbacks are a sign that the child may want to be its own actor πŸ”Ž

State management with actors

Instead of passing props down, the parent machine can spawn the child actor and keep it in context.

const searchPageMachine = setup({
  types: {
    context: {} as {
      readonly searchFormActor: ActorRefFrom<typeof searchFormMachine>;
    },
    events: {} as { type: "search.submitted"; query: string },
  },
  actors: {
    searchForm: searchFormMachine,
  },
}).createMachine({
  context: ({ spawn }) => ({
    searchFormActor: spawn("searchForm", { id: "searchForm" }),
  }),
  on: {
    "search.submitted": {
      actions: ({ event }) => {
        console.log("Search for", event.query);
      },
    },
  },
});

Inside the component, the parent machine passes the child actor from context.

function SearchPage() {
  const [snapshot] = useMachine(searchPageMachine);

  return <SearchForm actor={snapshot.context.searchFormActor} />;
}

The child component only receives the full actor that owns its behavior (type-safe with ActorRefFrom):

  • useSelector to extract context values
  • actor.send to send events to its own machine
function SearchForm({ actor }: { actor: ActorRefFrom<typeof searchFormMachine> }) {
  const query = useSelector(actor, (snapshot) => snapshot.context.query);

  return (
    <form
      onSubmit={(event) => {
        event.preventDefault();
        actor.send({ type: "submit" });
      }}
    >
      <input
        value={query}
        onChange={(event) => {
          actor.send({
            type: "query.changed",
            query: event.currentTarget.value,
          });
        }}
      />
    </form>
  );
}

The child machine owns the child behavior.

Callbacks are handled outside React components πŸͺ„

The machines communicate with each other using sendTo and sendParent, all outside components:

const searchFormMachine = setup({
  types: {
    context: {} as { readonly query: string },
    events: {} as
      | { type: "query.changed"; query: string }
      | { type: "submit" },
  },
}).createMachine({
  context: { query: "" },
  on: {
    "query.changed": {
      actions: assign(({ event }) => ({ query: event.query })),
    },
    submit: {
      actions: sendParent(({ context }) => ({
        type: "search.submitted",
        query: context.query,
      })),
    },
  },
});

searchFormMachine is now reusable for all "search" use cases (just spawn another actor), and its behavior can be implemented (and tested) in isolation.

Note: Parent and child share the search.submitted event. You may want to use satisfies to strongly type the event πŸ™Œ

The Rule of Thumb

A DOM handler is fine:

<form onSubmit={...} />
<input onChange={...} />

But a custom component handler is worth questioning:

<SearchForm onSubmit={...} />
<FoodPicker onSelect={...} />
<Dialog onConfirm={...} />

In an XState project, that often means: this component has its own behavior, and that behavior probably deserves its own actor.


When a custom component starts collecting onX props, ask:

Is this component just rendering UI, or does it have its own behavior?

If it has behavior, model it as an actor. Let the parent machine spawn it, keep the actor ref in context, and pass that actor to the component.

Note: I have a custom oxlint rule that flags all onX props to agents, and hints to refactor those using actors πŸͺ„