--- title: "State Machines in React: When Booleans Start Lying to You" author: "Maksym Kuzmitskyi (MaximusFT)" published: "2026-07-31T23:04:07.142Z" updated: "2026-08-25T21:25:00.528Z" url: "https://zyvop.com/state-machines-in-react-when-booleans-start-lying-to-you-o0o9o" canonical: "https://ma-x.im/blog/react-playbook-state-machines" tags: ["react","React Playbook","Architecture"] --- # State Machines in React: When Booleans Start Lying to You ![Cover image]() ![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: ```ts // 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: ```ts 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. ```ts 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.