ZYVOPMulti-Platform Sync
SeriesAI NewsWhy ZyVOPJoin Discord
LoginGet Started
ZYVOPMulti-Platform Sync

The Developer Publishing Hub. Write once, publish everywhere, and make your work citation-ready with built-in SEO, AEO, and GEO discovery support. Zero reader paywalls.

Content

  • Categories
  • Tags
  • Badges
  • Leaderboard
  • Write Article
  • Newsletter

Company

  • About Us
  • Why ZyVOP
  • Changelog
  • Compare Platforms
  • Hashnode vs ZyVOP
  • DEV vs ZyVOP
  • Developer API & CLI
  • Author Handbook
  • Contact

Connect

  • Privacy Policy
  • Terms of Service
  • Cookie Policy
  • DMCA Policy
  • Code of Conduct

ยฉ 2026 ZyVOP. Developer Publishing Hub.

Zero paywalls ยท Full content ownership
All systems operational
HomeState Machines in React: When Booleans Start Lying to You

State Machines in React: When Booleans Start Lying to You

Maksym Kuzmitskyi (MaximusFT)
Maksym Kuzmitskyi (MaximusFT)
Staff Frontend Engineer
July 31, 2026Updated August 25, 2026
6 min read
State Machines in React: When Booleans Start Lying to You
#react#React Playbook#Architecture

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.

Comments (0)

Login to post a comment.

Maksym Kuzmitskyi (MaximusFT)
Maksym Kuzmitskyi (MaximusFT)

Staff Frontend Engineer

Passionate developer sharing knowledge about modern web technologies and best practices.

Subscribe to Maksym Kuzmitskyi (MaximusFT)'s Newsletter

Direct email dispatches when new stories are published. Zero algorithms.

More from Maksym Kuzmitskyi (MaximusFT)

View profile

You Only Get to See It From the Outside Once

The most valuable thing an experienced engineer brings to a new project isn't a skill โ€” it's a vantage point. For a short window, you can still see the thing from the outside, the way a user or a newcomer does. Six months in, that view is gone. And most teams burn it in the first week without noticing.

6 minAug 11

The Command Palette Is an Architecture, Not a Widget

The theming article ended on a move from how an app looks to how power users drive it. The command palette โ€” the Cmd+K menu that fuzzy-searches everything you c...

5 minAug 7

Code-Splitting Is a Boundary Decision, Not a Bundle Trick

The command palette article closed by promising the last piece of this look-and-feel stretch, and it's the one that looks the most like a solved problem: code-s...

6 minAug 7

You Probably Don't Need Multi-Agents

I work across six or seven repositories on one project โ€” a big hybrid thing, part microfrontend, part backend, several apps that all talk to each other. When I ...

6 minAug 7

Analytics Is Architecture: Stop Sprinkling track() Everywhere

This cluster has been about how the parts of an app communicate โ€” event buses for ambient signals, feature flags for toggling behavior. Both articles kept circl...

5 minJul 30