Skip to content
Back to skills

Functional Typescript Xstate

ASecurity

Use when a lifecycle needs nested states, regions active at the same time, delayed transitions, or an actor per row, and whether a statechart beats a plain reducer.

  • 2 stars
  • 0 votes
  • 0 copies
  • 0 views
  • Added October 21, 2026
developmenttypescriptrustgoshelltesting

Security analysis

A100/100

Pro scans all 2 files and shows the line behind each finding

Scanned October 21, 2026

npx -y skills add yutna/functional-design-skills --skill functional-typescript-xstate --agent claude-code

Installs into .claude/skills of the current project.

Are you the author of Functional Typescript Xstate?

Add the live security badge to your README. It updates with every re-scan.

Security grade badge for Functional Typescript Xstate
[![Security: A — Skills Directory](https://www.skillsdirectory.com/api/skills/yutna-functional-typescript-xstate/badge)](https://www.skillsdirectory.com/skills/yutna-functional-typescript-xstate)

More formats (shields.io, HTML) on the badges page. Keep it an A: scan every change in CI with Pro.

Download with Pro
SKILL.md
---
name: functional-typescript-xstate
description: Use when a lifecycle needs nested states, regions active at the same time, delayed transitions, or an actor per row, and whether a statechart beats a plain reducer.
license: MIT
metadata:
  pack: functional-design-skills
  version: 3.3.0
---

# Functional Statecharts with XState

## Overview

A statechart is a state machine plus four things a plain union does not
have: states inside states, regions that are active at once, time as a
first-class trigger, and actors whose lifetime it owns. Each is a real
capability and each costs the same thing — the lifecycle stops being a
function you can call with two arguments and becomes a library value.

So this pack is mostly about the second half of that trade. The
modelling itself is unchanged and lives in
[functional-modeling-state-machines](../functional-modeling-state-machines/SKILL.md);
the TypeScript is in
[functional-typescript](../functional-typescript/SKILL.md).

**Check the installed version before writing code.** The shapes below
target XState 5, which reorganised everything around actors and renamed
`interpret` to `createActor`; version 4 spells most of this differently.

## When to use

- A lifecycle with states inside states
- Two parts of one lifecycle that are active at the same time
- A transition that fires because time passed, not because something
  happened
- One machine per row, each with its own lifetime
- Deciding whether any of that is true yet

What a person describes tells you which of these they have:

- Parts that each have their own steps are hierarchy: a compound
  state, one level down for each part.
- Parts that run or finish in either order are parallel regions: both
  are active at once, and the whole is done when every region is.
- A timeout or a delay is time as a trigger: a delayed transition,
  where a reducer would compare a stored timestamp each time it runs.

A lifecycle with any of these is what a statechart adds and a union
does not have. When you recommend one, name its price in the same
answer, as the next section states it: the states stop being cases of a
type, so the compiler no longer reports a state left unhandled.

Not for: a lifecycle a discriminated union and a transition function
already describe. That is most of them.

## When a union is still the answer

Ask which of the four the lifecycle actually has. If the answer is
none, a union plus a pure `(state, event) => state` is smaller, needs
no library, and can be called from a test with two arguments.

The cost of reaching too early is specific: the states become strings
inside a config object rather than cases of a type, so the compiler
stops telling you about a state you forgot to handle. You have traded
an exhaustiveness check for features you are not using. A machine also
drops an event that the current state has no transition for, and tells
the sender nothing. Where the sender has to learn of the refusal, as
rule 4 of
[functional-modeling-with-algebraic-types](../functional-modeling-with-algebraic-types/SKILL.md)
asks of a table over a state and a command, it asks
`snapshot.can(event)` before it sends, and a test covers the pairs that
are refused.

## Notation mapping

| Neutral notation   | XState                                   |
| ------------------ | ---------------------------------------- |
| Choice of states   | `states` in the machine config           |
| Transition         | `on: { EVENT: { target } }`              |
| Guard              | `guard` naming a predicate in `setup`    |
| Instruction        | A named action or an invoked actor       |
| Substates          | Nested `states` under a parent state     |
| Concurrent regions | `type: 'parallel'`                       |
| Time as a trigger  | `after: { holdWindow: { target } }`      |
| Child actor        | `invoke` with `src`, `onDone`, `onError` |

XState calls everything a machine receives an event. `HOLD` and
`CONFIRM` are what the core calls commands, and an actor's answer,
arriving through `onDone` or `onError`, is what it calls an event.

## Core rules

1. Rule. **The machine owns the lifecycle.** Nothing outside it decides
   what state the thing it models is in, and no component keeps a second
   copy of the state value to make rendering easier.
2. Default. **Reach for a statechart only when a union falls short.** It falls
   short when the lifecycle has states inside states, regions that are active at
   once, a transition that time fires, or an actor whose lifetime the machine
   owns. Without one of those you have taken on a library and lost
   exhaustiveness.
3. Rule. **Guards are pure predicates over context and event.** A guard
   that reads a clock, a store, or the network is a transition
   pretending not to be one.
4. Rule. **Actions describe; the interpreter performs.** Keep the effect
   in the actor or at the edge, so the machine stays a value that can be
   stepped in a test.
5. Default. **`invoke` for work with a lifetime, an action for work without
   one.** Work has a lifetime when it can still be running after its state is
   left: a request, a timer, a subscription. An invoked actor is cancelled when
   its state is left, and work started any other way has no owner, which rule 8
   of
   [functional-making-effects-reliable](../functional-making-effects-reliable/SKILL.md)
   makes a defect. An action is for a step that is over at once, such as an
   `assign`.
6. Rule. **Context is replaced, not mutated.** `assign` returns the new
   value; writing through the draft is the mutation this pack exists to
   remove.
7. Default. **Test by stepping, not by running.** `transition` and
   `initialTransition` give the next state and the actions without an
   interpreter, timers, or waiting. Running an actor is for the few tests of the
   wiring, which show that an invoked actor starts and that its answer comes
   back as an event.

## Pattern

```ts
import { setup, assign, fromPromise } from "xstate";

type BookingContext = { readonly slot: SlotId | null };

const heldSlot = (context: BookingContext): SlotId => {
  if (context.slot === null) throw new Error("held without a slot");
  return context.slot;
};

const bookingMachine = setup({
  types: {
    context: {} as BookingContext,
    events: {} as { type: "HOLD"; slot: SlotId } | { type: "CONFIRM" },
  },
  delays: { holdWindow: 120_000 },
  actors: {
    requestConfirmation: fromPromise(
      async ({ input }: { input: { slot: SlotId } }) =>
        confirmHold(input.slot),
    ),
  },
}).createMachine({
  id: "booking",
  initial: "browsing",
  context: { slot: null },
  states: {
    browsing: {
      on: {
        HOLD: {
          target: "held",
          actions: assign({ slot: ({ event }) => event.slot }),
        },
      },
    },
    held: {
      after: {
        holdWindow: { target: "browsing", actions: assign({ slot: null }) },
      },
      on: { CONFIRM: { target: "confirming" } },
    },
    confirming: {
      invoke: {
        src: "requestConfirmation",
        input: ({ context }) => ({ slot: heldSlot(context) }),
        onDone: { target: "confirmed" },
        onError: { target: "held" },
      },
    },
    confirmed: { type: "final" },
  },
});
```

The hold expiring is `after`, not a `setTimeout` somebody has to
remember to clear. The confirmation is `invoke`, so leaving
`confirming` cancels it. Both are the features that justified the
library; without them this is a union with extra ceremony.

The timer moves the screen on, and the screen's lifecycle is what this
machine owns. The hold itself is the server's: whether it has really
expired is decided there, where the clock can be trusted, from the time
it was taken.

The chart guarantees a slot is in context whenever the machine is in
`held`, but XState 5 types one context for every state, so the type says
`SlotId | null` and cannot say more. `heldSlot` returns the slot or
throws, which is how
[functional-defining-errors-out-of-existence](../functional-defining-errors-out-of-existence/SKILL.md)
treats a state that should be impossible; a cast would claim the same
thing and check nothing.

The `{} as T` inside `types` is how XState 5 is told a type, and it
constructs nothing, so it is no licence to cast anywhere else.

## Testing

```ts
import { transition, initialTransition } from "xstate";

const [start] = initialTransition(bookingMachine);
const [held] = transition(bookingMachine, start, { type: "HOLD", slot: s1 });

expect(held.value).toBe("held");
expect(held.context.slot).toBe(s1);
```

No interpreter and no clock. A delayed transition is tested by
asserting the machine scheduled it, not by waiting for it.

## Red flags

- A machine with no hierarchy, no parallel region, no delay and no
  invoked actor
- A component holding a boolean that mirrors the machine's state
- A guard that reads anything but its own arguments
- An `assign` or a guard that performs an effect, where a named action
  or an invoked actor would describe it
- `setTimeout` beside a machine that has `after`
- A test that sleeps

## Common mistakes

- **Adopting it for one screen.** A statechart is a shared vocabulary;
  one machine in a codebase of reducers is a second idiom.
- **Strings where a union would do.** A transition's `target` is a
  string the compiler does not check: a misspelt one fails when the
  machine is created, not when the file compiles. Reading the state with
  `snapshot.matches()` is checked.
- **Putting the domain in the machine.** Pricing, eligibility and
  validation are pure functions the machine calls, not actions.
- **An actor per row without a lifetime.** If nothing cancels it and
  nothing waits for it, it is a promise with more machinery. See
  [functional-making-effects-reliable](../functional-making-effects-reliable/SKILL.md).
- **Storing derived data in context.** It goes stale exactly the way
  stored derived state always does.

## Related skills

- [functional-modeling-state-machines](../functional-modeling-state-machines/SKILL.md)
- [functional-typescript](../functional-typescript/SKILL.md)
- [functional-managing-state-immutably](../functional-managing-state-immutably/SKILL.md)
- [functional-separating-pure-core-from-shell](../functional-separating-pure-core-from-shell/SKILL.md)

## Further reading

- [machines.md](references/machines.md) covers hierarchy and parallel
  regions, actors and their lifetimes, delays, and how a machine is
  persisted and restored.

Files in this skill

  • SKILL.md10.2 KB
  • references/machines.md4.6 KB

Attribution

Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.

Comments

Loading comments…

Newsletter

Get the weekly list of safe new skills, and the ones we pulled.