Article · Personal blog
Modeling UI with state machines, not a pile of booleans
How a discriminated union and a tiny transition function kill impossible states like isLoading && isError, and where machines are overkill.
Every non-trivial component I have shipped eventually grows a cluster of flags: isLoading, isError, isOpen, isSubmitting. Each one is reasonable alone, but together they describe states that can never legally happen — and the bugs live exactly there.
The booleans drift into impossible states
Four booleans give you sixteen combinations. Maybe four of them are real; the rest are nonsense your code still has to survive. What does isLoading: true together with isError: true mean? Nothing — yet some race condition will produce it, and your render will dutifully show a spinner stacked on an error banner.
The screen I debugged the most in Mail.ru Cloud was an upload panel: a flag for uploading, one for the retry prompt, one for the cancel confirmation. The flags were set from three different handlers, and roughly a quarter of the support reports were just two of them being true at once.
A boolean tells you whether one thing is on. A state machine tells you which one of several things is happening — and forbids the rest.
Make illegal states unrepresentable
The fix is to stop modeling the axes and start modeling the states. Collapse the flags into a single status field and hang the data that only exists in that state off the same object. TypeScript turns this into a discriminated union, and the compiler refuses the impossible combinations for you.
type RequestState<T> =
| { status: 'idle' }
| { status: 'loading' }
| { status: 'success'; data: T }
| { status: 'error'; error: Error; canRetry: boolean };
Now data simply does not exist unless status === 'success', and there is no way to spell “loading and error”. When you switch on status, TypeScript narrows each branch and warns you if you forget one. The render function stops guessing and starts reading.
A hand-rolled machine before you reach for XState
A finite state machine is two things: a set of states and a transition(state, event) => state function. You do not need a library to start — a pure reducer is already a machine, and writing the transition by hand forces you to name every legal move.
function reduce(state: RequestState<User>, event: Event): RequestState<User> {
switch (state.status) {
case 'idle':
case 'error':
return event.type === 'FETCH' ? { status: 'loading' } : state;
case 'loading':
if (event.type === 'RESOLVE') return { status: 'success', data: event.data };
if (event.type === 'REJECT') return { status: 'error', error: event.error, canRetry: true };
return state;
case 'success':
return event.type === 'FETCH' ? { status: 'loading' } : state;
}
}
The important detail is the default of doing nothing. An event that arrives in a state that does not expect it is ignored, not mishandled. That single property kills a whole genre of bugs: the late RESOLVE from a request you already cancelled can no longer overwrite fresh state, because loading is the only status that listens for it.
Where machines genuinely pay off
Machines earn their keep wherever an interaction has memory and direction:
- Async flows — anything with retry, cancel, or a stale response that must be dropped. The transition table is where you decide, once, that a
REJECTafterCANCELdoes nothing. - Multi-step wizards — checkout, onboarding, a file-move dialog. “You can only reach payment from a validated cart” stops being a code comment and becomes an unreachable edge.
- Anything with a
back/cancelaffordance — because those are transitions too, and the machine makes the reverse paths as explicit as the forward ones.
The common thread: the next valid action depends on how you got here. That is the definition of state, and booleans model it badly.
Where they are overkill
I do not wrap a single toggle in a machine. A disclosure panel, a dark-mode switch, a isOpen dropdown with no sub-states — useState(false) is correct and a machine is ceremony. The honest test: if there is exactly one boolean and no event is illegal in any state, you have a toggle, not a machine. Reach for the heavier tool only when you catch yourself writing if (a && !b && c) to describe a screen.
XState is worth it once you want hierarchical states, parallel regions, delayed transitions, or a visualizer for non-engineers — not as the default for every fetch.
The transition table is living documentation
The quiet payoff is that the machine is the spec. A new teammate reading the reduce above learns every state the panel can be in and every way to leave it, in tens of lines, with no prose to fall out of date. When a designer asks “what happens if they hit retry mid-load?”, the answer is not tribal knowledge — it is the loading branch, which ignores FETCH.
In Slate I rebuilt the note sync indicator this way: synced, syncing, offline, conflict, with the transitions written out explicitly. The pile of flags that preceded it had a class of bugs where the UI claimed “saved” while a request was still in flight. With states it could not happen — “saved” is simply not reachable from syncing without a RESOLVE first, and the table says so out loud.