XState, Actors, and What the Stately Argument Actually Buys

Why a hand-rolled retry double-charged a Stripe customer because the cancel state was implicit, and what XState 5's setup-plus-actors model gives you that useReducer does not.

By Jovani Pink April 19, 2026 11 min — Systems & Complexity Notes

Outcome focus: Reader can write an XState 5 machine using the setup pattern, distinguish invoked from spawned actors, decide when to graduate from useReducer to a state machine library, and read XState code as a structured argument rather than a configuration object.

Part 2 of 4. Part 1: When the State Chart Pays Off. Part 3: State Machines in Python: from xstate-python to LangGraph. Part 4: State Machines in Go, Elixir, Swift, and Zig.

A checkout form had a retry button. The user clicked submit. The Stripe charge succeeded on the server, but the client did not see the response in time. The user clicked retry. The hand-rolled retry path issued a second charge. The customer saw two charges on their statement, called support, and refused the second. The team refunded the duplicate, wrote an incident note, and added an idempotency key.

The idempotency key was the right fix. The wrong fix would have been to add another boolean flag and another effect to read it. The retry double-charged because the cancel and the in-flight states were implicit. The retry button was rendered on a different code path from the one that knew the previous request was still pending. There was no place in the code where "we are waiting for a response we have not seen yet" was a named state with an explicit transition.

A state machine prevents this category of bug structurally. The double-charge cannot happen because the retry transition is illegal while the machine is in the in-flight state. The discipline does not need to be added later, after the bug; it is the shape of the code from the start.

This post is about XState 5, which is the library most teams reach for in TypeScript and React when they decide to take that discipline seriously. The post is not an exhaustive XState reference. It is a working argument for the parts of XState that pay back the up-front cost.

David Khourshid and the Stately Argument#

David Khourshid (publishing as @davidkpiano) has spent most of the past decade arguing that statecharts belong in frontend code. He started XState as an open-source project, built a company around it (Stately), and shipped a visual editor (Stately Studio) that lets teams sketch a chart and export it as XState code. The argument behind the work is that statecharts are not too academic for product code; the argument behind the company is that the visual editor is the missing collaboration surface between engineers, designers, and product managers.

I am sympathetic to most of the argument. The part I would push back on is the implication that every workflow benefits from a chart. The decision matrix in the previous post in this series names the cases where a chart pays back the cost; outside those cases, a state machine library is decoration. The case for XState specifically is that when the workflow does want a chart, the library gives you a typed, testable, visualizable artifact that no plain useReducer setup matches.

XState 5 is the current major version. The API in this post is XState 5. Code on the internet that uses Machine(...) (capital M, no setup) is XState 4 and has been deprecated for over a year. If you are reading XState examples and the file imports look unfamiliar, check the version in package.json before copying the patterns.

The setup Pattern#

XState 5's recommended entry point is setup({ types, actors, actions, guards, delays }).createMachine({...}). The setup call carries the type information and the named sources (action implementations, guard implementations, child actor logic); createMachine consumes those names and returns a machine. The split exists because TypeScript's inference is better when types are scoped to the setup call, and because the same setup call can produce multiple related machines that share types.

A small machine for the checkout form looks like this. The state names are the same five from Part 01 (idle, submitting, error, success, retrying). The events are typed with discriminated unions so that an unknown event payload becomes a compile-time error.

import { setup, assign, fromPromise } from 'xstate';
 
type CheckoutContext = {
  amountCents: number;
  attempt: number;
  idempotencyKey: string;
  error: string | null;
};
 
type CheckoutEvent =
  | { type: 'SUBMIT' }
  | { type: 'RETRY' }
  | { type: 'CANCEL' }
  | { type: 'DISMISS' };
 
const checkoutMachine = setup({
  types: {
    context: {} as CheckoutContext,
    events: {} as CheckoutEvent,
  },
  actors: {
    submitCharge: fromPromise(async ({ input }: { input: CheckoutContext }) => {
      const response = await fetch('/api/charge', {
        method: 'POST',
        headers: {
          'content-type': 'application/json',
          'idempotency-key': input.idempotencyKey,
        },
        body: JSON.stringify({ amount_cents: input.amountCents }),
      });
      if (!response.ok) {
        throw new Error(`charge failed: ${response.status}`);
      }
      return response.json();
    }),
  },
  actions: {
    incrementAttempt: assign({
      attempt: ({ context }) => context.attempt + 1,
    }),
    recordError: assign({
      error: ({ event }) => (event as { error: Error }).error.message,
    }),
    clearError: assign({ error: null }),
  },
  guards: {
    canRetry: ({ context }) => context.attempt < 3,
  },
}).createMachine({
  id: 'checkout',
  initial: 'idle',
  context: {
    amountCents: 0,
    attempt: 0,
    idempotencyKey: '',
    error: null,
  },
  states: {
    idle: {
      on: { SUBMIT: 'submitting' },
    },
    submitting: {
      entry: ['incrementAttempt', 'clearError'],
      invoke: {
        src: 'submitCharge',
        input: ({ context }) => context,
        onDone: 'success',
        onError: { target: 'error', actions: 'recordError' },
      },
      on: { CANCEL: 'idle' },
    },
    error: {
      on: {
        RETRY: { target: 'retrying', guard: 'canRetry' },
        CANCEL: 'idle',
      },
    },
    retrying: {
      entry: ['incrementAttempt', 'clearError'],
      invoke: {
        src: 'submitCharge',
        input: ({ context }) => context,
        onDone: 'success',
        onError: { target: 'error', actions: 'recordError' },
      },
      on: { CANCEL: 'idle' },
    },
    success: {
      on: { DISMISS: 'idle' },
    },
  },
});

The retry double-charge cannot happen with this machine. While the machine is in submitting, the only legal events are CANCEL (returns to idle) and the actor's onDone or onError (transitions to success or error). There is no RETRY transition out of submitting, so a RETRY event sent during submitting is dropped. The user clicking the retry button while a charge is in flight has no effect, because the retry button only renders in the error state, where RETRY is a legal event.

The two structural protections matter together. The render layer renders the retry button only in error, which is the cosmetic protection. The machine refuses RETRY outside error, which is the architectural protection. Either one alone would have prevented the bug; both together mean the bug cannot return when a future engineer adds another retry path.

Invoked Versus Spawned Actors#

XState's actor model is borrowed from Carl Hewitt's 1973 actor model, which Erlang's BEAM implemented as the foundation of OTP. The borrowing is partial; XState's actors are not BEAM processes, do not run in their own threads, and do not get supervision trees for free. They are, however, isolated computations with their own state that communicate via messages, which is the part of the actor model that matters for client-side workflow modeling. The Erlang and OTP version of the actor model is the topic of Part 4 in this series; for XState, the relevant distinction is between invoked and spawned actors.

An invoked actor has a lifecycle bound to a state. It starts when the parent enters the state and stops when the parent leaves. The submitCharge actor in the example above is invoked from the submitting state. If the user cancels mid-charge, the parent transitions to idle, which causes the invoked actor to stop. If the actor was a fetch with an AbortController, the abort fires automatically on stop. This is the right shape for "do one thing while in this state" workflows.

A spawned actor has an independent lifecycle. The parent uses spawn (in actions) or spawnChild to create the actor; the parent keeps a reference and can stop it later. Spawned actors are right when the number of actors is dynamic (a list of todo items, each with its own machine) or when an actor needs to outlive the state that created it (a long-running websocket subscription that survives multiple form interactions).

A small spawned-actor example for a list of editable items:

import { setup, assign, spawnChild } from 'xstate';
 
const todoMachine = setup({
  // ...types and actors omitted for brevity
}).createMachine({
  initial: 'editing',
  states: {
    editing: { on: { SAVE: 'saved' } },
    saved: { on: { EDIT: 'editing' } },
  },
});
 
const listMachine = setup({
  types: {
    context: {} as { items: Array<{ id: string; ref: ActorRefFrom<typeof todoMachine> }> },
    events: {} as { type: 'ADD'; id: string } | { type: 'REMOVE'; id: string },
  },
}).createMachine({
  context: { items: [] },
  on: {
    ADD: {
      actions: assign({
        items: ({ context, event, spawn }) => [
          ...context.items,
          { id: event.id, ref: spawn(todoMachine, { id: event.id }) },
        ],
      }),
    },
    REMOVE: {
      actions: assign({
        items: ({ context, event }) => context.items.filter((i) => i.id !== event.id),
      }),
    },
  },
});

The list machine spawns a new todo machine for each item added, and each todo machine has its own editing/saved lifecycle independent of the list. Removing an item from the list does not stop the spawned actor automatically (the spawned actor outlives the list event); a complete implementation would call stop on the actor reference before removing it from the array, or rely on garbage collection if the actor has no observers. This is the shape of bug that spawned actors invite if the team is not careful, and it is the reason invoked actors are the right default whenever the lifecycle is naturally state-bound.

What useReducer Does Not Do#

A reasonable React reader will object that useReducer already supports state-and-event modeling and that XState is a heavier alternative. The objection is half right. useReducer plus an exhaustive switch statement plus a discriminated union of events plus an explicit state field plus a guard pattern in each case is, in fact, a state machine. The patterns are the same. What useReducer does not give you, and what XState does:

  • Hierarchical states. A submitting state that has substates (validating, requesting, confirming) is awkward in useReducer. In XState, it is a nested states object.
  • Parallel states. A form that is independently in one of two dirty states (clean, dirty) and one of three validation states (pending, valid, invalid) needs four flags in useReducer and is one type: 'parallel' declaration in XState.
  • Invoked side effects. XState's invoke ties an effect's lifecycle to a state. useReducer requires a separate useEffect per side effect, with a manual cleanup function and a manual dependency array, which is where most "stale closure" bugs live.
  • Visualization. Stately Studio reads an XState machine and renders the chart. There is no equivalent for useReducer.
  • Testability. @xstate/test and the model-based testing pattern generate tests from the machine. useReducer reducers are testable as plain functions, but the test surface is per-action, not per-path-through-the-graph.

The tipping point is roughly: if the workflow has hierarchy, parallelism, or more than two invoked effects, XState pays back its cost. If it has none of those, useReducer is fine and the dependency on XState is decoration. The form in this post hits the "more than two invoked effects" threshold (the charge, the auto-cancel timer, the validation, the analytics event), so XState earns its place.

Stately Studio and the Visual Editor Argument#

Stately Studio is the Stately company's hosted editor. It reads XState machines and renders interactive charts; it also lets non-engineers edit the chart and export the result. The argument I find compelling is that for a workflow that crosses team boundaries (engineering, product, design), the chart is a shared artifact in a way that the code is not. A product manager can read a Stately Studio chart and propose adding a state, removing a transition, or guarding an event. They cannot do the same to a TypeScript file with twenty-five percent confidence.

The argument I would temper is the implication that every team needs the editor. For a single-engineer side project, the editor is overkill, and the code-first workflow is faster. For a product team where the form's flow is itself a product surface, the editor is an honest collaboration tool. As of April 2026, Stately offers a free tier and a paid tier; the paid tier adds team features and live cursors. Verify the current pricing at stately.ai before committing the team to a budget; the product surface has shifted at least twice in the past three years.

Honest Limits#

Two cautions.

First, XState 5 is a moving target. The major version has been stable for over a year, but the API has had small additions in patch releases (new actor logic creators, new helpers around input typing). Pin a major version, read the changelog before upgrading, and do not pull in a minor version difference between the runtime (xstate) and the React adapter (@xstate/react) without checking compatibility.

Second, XState's actors are not Erlang/OTP actors. They do not run in their own thread. They do not survive a page refresh without explicit persistence. They do not get supervision for free. The actor pattern in XState is the right vocabulary for client-side workflow isolation, but a client-side actor is not a process, and a team that brings BEAM intuitions to XState will be disappointed by the runtime guarantees. The runtime question is what Part 4 of this series is about.

Close#

The next post moves to Python, where XState's discipline meets a different ecosystem. The xstate-python port exists, but it is pre-1.0 and missing significant XState 5 features. LangGraph, Temporal, and transitions are the practical alternatives in Python. Part 3 maps the landscape and names a contribution roadmap for the xstate-python repo I work on, if you want to help.

For TypeScript and React, the move this week is to identify one form, one workflow, or one component in your codebase that hits the decision matrix from Part 1 and rewrite it as an XState machine. Use setup. Use invoke, not useEffect plus a fetch. Add the @xstate/test integration. The first machine takes a half-day. The second takes an hour. By the third, you will have a sense for which workflows in your codebase are state-machine-shaped and which are not, and the team's vocabulary will catch up to the discipline.

Back to all writing
On this page
  1. David Khourshid and the Stately Argument
  2. The setup Pattern
  3. Invoked Versus Spawned Actors
  4. What useReducer Does Not Do
  5. Stately Studio and the Visual Editor Argument
  6. Honest Limits
  7. Close