Where the machine ends
Where does one state machine stop and the next begin? What goes in, what stays out, how do machines cooperate, and how do their diagrams stay honest?
Machines model behaviours, not apps #
The thing being modelled is not “the application”; it’s not even “the component”; it’s a behaviour. One behaviour with a single lifecycle. A story with a beginning, middle, and a discrete set of possible moves: the draft save flow, the checkout wizard, the connection to the sync server.
Your state machine is not trying to model the whole app; that was never the goal. An application is a collection of running machines.
Imagine explaining the feature to product at a whiteboard. What you would draw is the experience a person goes through when they use a feature (the possible states, how they connect, the “only if” conditions); that is the machine. You would never draw a button’s hover colour, the exact list items in the cache, or which form field has focus; all that stays out.
The three bins #
Finite modes become state, and unbounded data becomes context. Context is what we typically think of as state if you’re coming from React (so context is what state machines call state). The third bin is outside the machine entirely: anything the user’s flow does not depend on.
| Bin | Test | Examples |
|---|---|---|
| State | A finite mode the flow moves through; the UI or the rules differ per mode. | editing, saving, givenUp |
| Context | Data a guard or action reads to decide or act — or that arrived via the flow’s own events. | retries, error |
| Outside | Nothing in the machine ever branches on it, and another tool already owns it. | Server cache (TanStack Query), form fields as the user types, route, scroll, hover/focus |
Here the machine is a choreographer, not a warehouse. It holds what it needs to decide where the flow goes next and no more.
If an editor has a character count, shown live in the footer as the user types. Is that state or context? Well, if no guard ever reads it, it’s plain React state next to the input, and the machine never has to hear about it.
Duplicating your server cache into context is a mistake — TanStack Query already owns freshness, refetching, and invalidation; the machine at most cares about a mode (“do we have what we need to proceed?”), never the rows themselves.
How diagrams stay honest #
So how can a machine that ignores hover states and cache rows be an “accurate model”?
Because accuracy comes from enforcement, not coverage.
A map of the London Underground says nothing about street traffic — and is 100% accurate, because it only claims what it claims: these stations, these lines.
Your diagram claims exactly two things: these are the states this behaviour can be in, and these are the legal moves between them.
Those claims hold not because you were careful but because the boundary is mechanical: the component renders from the snapshot and can only send events in. There is no API for the component to reach into the actor and set its state.
Unmodelled things can’t make the diagram lie, because the diagram makes no claims about them.
Duplicated authority is the exception here. If a component also has if (retries < 3) gating the retry button, the flow now has two owners — and when product changes 3 to 5, one of them will be wrong. The diagram lies not by omission but by competition. Rule: every decision about the flow has exactly one owner, and if it’s a decision about where the flow goes, the owner is a guard. The component doesn’t re-derive the answer; it asks the snapshot —
snapshot.can({ type: 'retry' })tells you whether the event would do anything, straight from the machine’s own rules.
One machine or two? #
Machines cooperate the same way the component cooperates with your machine: events in, snapshots out. In XState, this is the actor model — every running machine is an actor with a mailbox, and actors never reach into each other’s state; they only send messages. That constraint is what makes the boundary question answerable:
| Signal | Verdict |
|---|---|
| It’s a separate story with its own beginning and end, its own lifetime, or it needs to exist in N copies (one per list row). | Separate machine. They talk by sending events. |
| You keep wanting machine A to peek at machine B’s current state to decide anything. | One machine. Constant peeking means it was one behaviour all along — the split was wrong. |
| Two concerns share one lifetime but never constrain each other (playing/paused × muted/unmuted), and combining them multiplies states. | One machine, parallel regions — a statechart feature. Not two machines. |
| A chunk of one machine is really a sub-process with its own start and finish (the save request itself). | Child actor, invoked by the parent — next lesson. |
Who needs to know what?
That’s the smell test.
