State Machines in React: When Booleans Start Lying to You

Jul 21, 2026
12 min read
State Machines in React: When Booleans Start Lying to You

Look at a component you wrote recently and count its status booleans. isLoading, isError, isSuccess, isEditing, isSubmitting. Five of them. That's not five states — that's two-to-the-fifth, thirty-two combinations, and you only meant for about four of them to exist. What is your UI supposed to do when isLoading and isError are both true? You never decided, because you never meant for that to happen. But nothing in the code stops it, so one day a fast retry sets both, and you get a spinner layered over an error message, and a bug report you can't reproduce.

This is the quiet tax of modeling state as loose booleans. Every flag you add doubles the space of possible states, most of the new ones are nonsense, and your code silently becomes responsible for all of them. The undo/redo article got us thinking about changes as explicit, named transitions. A state machine is that same idea applied to the states themselves.

Impossible states, made impossible

The core move of a state machine is to stop representing status as independent booleans and start representing it as one value that can only be one of a fixed set of named states:

// before: 32 representable combinations, ~4 intended type Status = { isLoading: boolean; isError: boolean; isSuccess: boolean; }; // after: exactly the states that can actually exist type Status = | { state: 'idle' } | { state: 'loading' } | { state: 'success'; data: Data } | { state: 'error'; message: string };

Look at what the second version buys you beyond tidiness. loading and error can no longer both be true — the type won't let you construct it. And notice data only exists in the success state and message only in error. You can't read data while loading because there is no data while loading. The compiler now enforces the thing you were previously enforcing by hoping. This is the payoff people mean when they say "make impossible states unrepresentable" — it's not aesthetics, it's deleting whole categories of bug by construction.

Every boolean you add to describe status doubles your state space and trusts you to defend the nonsense corners by hand. A state machine shrinks the space to the states that can really happen and makes the rest impossible to write.

You often don't need a library

Here's the part that gets lost in the XState hype: a state machine is a concept, not a dependency. Most of the time a useReducer with explicit states is a complete, honest state machine, and it's the right amount of tool:

type State = | { state: 'idle' } | { state: 'loading' } | { state: 'success'; data: Data } | { state: 'error'; message: string }; type Event = | { type: 'FETCH' } | { type: 'RESOLVE'; data: Data } | { type: 'REJECT'; message: string }; function reducer(state: State, event: Event): State { switch (state.state) { case 'idle': return event.type === 'FETCH' ? { state: 'loading' } : state; case 'loading': if (event.type === 'RESOLVE') return { state: 'success', data: event.data }; if (event.type === 'REJECT') return { state: 'error', message: event.message }; return state; // success / error handle their own transitions... default: return state; } }

The thing I want you to notice is that transitions are keyed on the current state first. A RESOLVE event while idle does nothing, because you can't resolve a fetch you never started. That's a transition guard falling out naturally from the structure — the machine ignores events that don't make sense in the current state, instead of trusting every handler to check. For a data fetch, a form submit, a toggle with a few modes, this hand-rolled machine is all you need, and reaching for a library would be ceremony.

When XState actually earns its weight

So when do I pull in a real state-machine library? When the flow gets complex enough that the machinery around transitions becomes the hard part — and a reducer starts sprawling. Concretely, when I hit things like these together:

  • Guards — a transition is only allowed under a condition ("can't go to review unless every step validated").
  • Side effects tied to transitions — entering submitting should fire a request; leaving editing should autosave. XState models these as actions and services attached to states, instead of effects scattered across components.
  • Hierarchy and parallel states — a media player that is playing and independently muted and fullscreen, or a wizard where each step has its own sub-states. Statecharts express "these regions are active at once" without the boolean explosion coming back through the side door.
  • A flow worth visualizing — XState machines can be rendered as an actual diagram, which for a gnarly multi-step form or checkout becomes a shared source of truth that a PM or designer can read.
import { createMachine } from 'xstate'; const checkoutMachine = createMachine({ id: 'checkout', initial: 'cart', states: { cart: { on: { NEXT: 'shipping' } }, shipping: { on: { NEXT: { target: 'payment', guard: 'shippingValid' }, BACK: 'cart' } }, payment: { on: { NEXT: { target: 'review', guard: 'paymentValid' }, BACK: 'shipping' } }, review: { on: { SUBMIT: 'placing', BACK: 'payment' } }, placing: { invoke: { src: 'placeOrder', onDone: 'done', onError: 'review' } }, done: { type: 'final' }, }, });

Read that and you can see the entire flow — every state, every legal move, every guard, where the order actually gets placed — in one place. The kind of flow this shines on is exactly the sort of thing I've built before: a policy builder with conditional, dependent steps where "which screen is even valid right now" is genuinely hard. When the answer to "what can happen next" stops being obvious, a statechart stops being overhead and starts being the only readable description of the feature.

The cost, said plainly

XState has a learning curve and a vocabulary — actors, guards, actions, services, context — and if you drop it onto a problem that a four-state reducer would've solved, everyone on the team pays that tax for nothing. Do not put a toggle in a statechart. Do not model idle → loading → success → error with the full actor system if a reducer says it in fifteen lines. The tool is proportional: booleans for one or two independent flags, a useReducer machine for a real-but-contained flow, and XState when hierarchy, guards, side effects, and visualization all show up at once. Matching the weight of the tool to the weight of the problem is most of the skill.

Closing the cluster

That's the end of this run on state and data at scale, and there's a single thread through all five pieces. We started with where state even lives — memory versus storage, and why confusing them breaks things. Then one coherent copy of server data instead of five drifting duplicates. Then optimistic updates — showing intent early and reconciling with truth. Then undo/redo — changes as explicit, reversible transitions. And now state machines — the states themselves made explicit and finite. Every step was the same instinct: take something ambient and implicit, and make it a thing you can see, name, and reason about.

Which is exactly the instinct the next cluster needs, because it moves from state inside a part of the app to communication between the parts. Once you have well-modeled features, the new failure mode is how they talk to each other — and the moment you reach for a global event bus so anything can shout at anything, you can bring back all the implicitness you just spent five articles removing. When does an event bus actually help, and when is it just spaghetti with better branding? That's where we go next.

If you've got a component right now with three or more status booleans in it, try the exercise: write down the states it's actually allowed to be in. If that list is shorter than two-to-the-number-of-booleans — and it always is — you've just found the impossible states your code is currently paying to survive. Tell me how many you found; the gap is usually bigger than people expect.

Telegram

More than a blog post

I share frontend news and the reasoning behind it throughout the day. Pick the language that feels natural to you.

Need to discuss your project? Get in touch.