
XState: Taming Complex UI State — From 20 If-Statements to a State Machine
Managing payment flow state with if-statements and boolean flags led to bug hell. XState's state machines made impossible states truly impossible.

Managing payment flow state with if-statements and boolean flags led to bug hell. XState's state machines made impossible states truly impossible.
Tired of naming classes? Writing CSS directly inside HTML sounds ugly, but it became the world standard. Why?

How did a simple calculator start thinking logically? I explore the essence of computers through the Turing Machine and connect it to state management in my code.

Class is there, but style is missing? Debugging Tailwind CSS like a detective.

For visually impaired, for keyboard users, and for your future self. Small `alt` tag makes a big difference.

I was building a payment flow. Card input, validation, payment request, result handling. Seemed simple. So I started adding state with useState, one boolean at a time.
const [isLoading, setIsLoading] = useState(false);
const [isError, setIsError] = useState(false);
const [isSuccess, setIsSuccess] = useState(false);
const [isPending, setIsPending] = useState(false);
const [isRetrying, setIsRetrying] = useState(false);
Two booleans at first. Then edge cases appeared. Retry logic. 3D Secure authentication. Timeout handling. Before I knew it, five booleans were juggling in the air simultaneously.
Then a bug report came in from QA.
"Payment completed but the loading spinner keeps spinning."
I opened the console. isLoading: true. isSuccess: true. Both. At the same time.
How? I wasn't sure. Somewhere in the code, I'd missed a setIsLoading(false) call, or there was a race condition. Either way, a state that should have been impossible in reality existed in the code without complaint.
That was the real problem.
Five boolean flags means 2⁵ = 32 possible state combinations. How many of those were actually valid in my payment flow? Roughly these:
Seven. Seven valid states, but the code allowed 32 combinations. The other 25 combinations rested entirely on an implicit promise that "this shouldn't happen." That promise breaks easily. Miss one setIsLoading(false), let async operations race, or have a teammate modify the code without fully understanding the state flow.
Think of it like juggling. One boolean is tossing one ball and catching it. Two booleans, two hands, two balls. Five booleans? Unless you're a professional juggler, one ball hits the floor. And that dropped ball shows up in your UI as "loading spinner during success state."
The component looked like this:
function PaymentButton() {
const [isLoading, setIsLoading] = useState(false);
const [isError, setIsError] = useState(false);
const [isSuccess, setIsSuccess] = useState(false);
const [isPending, setIsPending] = useState(false);
const [isRetrying, setIsRetrying] = useState(false);
const [errorMessage, setErrorMessage] = useState('');
const handlePayment = async () => {
setIsLoading(true);
setIsError(false); // resetting previous error... is this right?
setIsPending(false); // and this?
try {
const result = await requestPayment();
if (result.requiresAuth) {
setIsLoading(false);
setIsPending(true); // waiting for 3DS
await handle3DS(result.authUrl);
setIsPending(false);
setIsLoading(true); // back to loading
}
const finalResult = await confirmPayment(result.id);
setIsLoading(false);
setIsSuccess(true);
} catch (error) {
setIsLoading(false);
setIsError(true);
setErrorMessage(error.message);
if (isRetryable(error)) {
setIsRetrying(true); // isError is still true — intentional?
}
}
};
if (isLoading && !isPending) return <Spinner />;
if (isPending) return <AuthWaiting />;
if (isRetrying) return <RetryButton />; // isError is also true here
if (isError) return <ErrorMessage text={errorMessage} />;
if (isSuccess) return <SuccessScreen />;
return <PayButton onClick={handlePayment} />;
}
Reading through this, I kept second-guessing myself. Was isError: true while isRetrying: true intentional or a mistake? I couldn't be certain. Neither could anyone reading the code after me.
When I first heard about XState, my reaction was "overengineering." I'd used Redux before, and that was already complex enough. I didn't want another library to learn.
But once I understood the concept, my thinking changed.
The core of a state machine is simple: at any given moment, exactly one state exists. Think of a traffic light.
A traffic light has three states: red, yellow, green. It can never be red and green simultaneously. The transition "red to green" is explicitly defined, and the light only changes state along declared paths. Impossible states are blocked by the system design itself.
Boolean flags are the opposite. It's like managing "is it red," "is it yellow," "is it green" as separate booleans. If a bug somewhere sets isRed = true and isGreen = true at the same time, the system says nothing and holds that state.
Three core properties of a Finite State Machine (FSM):
After installing XState and @xstate/react, I defined the state machine for the payment flow — explicit states and transitions instead of boolean spaghetti.
The machine definition becomes a living specification of the system. Seven named states. Each state declares exactly which events it responds to and where those events lead. No state can "accidentally" co-exist with another — the machine tracks exactly where it is at all times.
When the component uses this machine with useMachine, the rendering logic becomes a series of state.matches() checks. state.matches('requesting') && state.matches('success') is simply not possible. requesting and success are different state nodes, and the machine can only occupy one at a time.
Every transition is also explicit. From requesting, the machine can only go to confirming, authenticating, or failed. There's no path where calling a setter function accidentally creates an undefined combination. All transitions follow declared routes.
One of the things that clicked hardest when I started using XState was the visualization tooling.
Paste your state machine definition into XState Visualizer and it renders a graph — states as nodes, transitions as labeled edges. Which events fire from which state, and where they lead, becomes immediately visible.
With boolean spaghetti? You'd need to draw it on a whiteboard to understand the full flow. Anyone new to the code has to mentally assemble every if-else branch just to understand what can happen.
A state machine is its own documentation. Even without the visualizer, reading the code makes the flow clear: "in this state, this event goes here." Debugging changes too. With booleans, tracking a bug means tracing every setState call in the right order. With a state machine, you just need to know "what is the current state" and "what event fired" — the machine narrows the problem space immediately.
This is like a subway map. If Seoul subway navigation worked via booleans, you'd be checking latitude and longitude coordinates to figure out your current station. A state machine says "you are at Hongdae station, the next station is Hapjeong." Your current position is named, and where you can go is predetermined. No coordinate math required.
Honestly, not every piece of state management needs XState. Using createMachine for a single button's loading state or a modal open/close is genuine overkill.
My rough heuristic:
Consider a state machine when:Before going full XState, I used a stepping stone: a string literal union type.
type PaymentStatus =
| 'idle'
| 'requesting'
| 'authenticating'
| 'confirming'
| 'retrying'
| 'success'
| 'failed';
const [status, setStatus] = useState<PaymentStatus>('idle');
Just replacing five booleans with a single named state value makes "two states at once" impossible — TypeScript enforces it. If transition logic stays simple, this is enough. When async flows arrive or complexity climbs, that's the moment to reach for XState.
n boolean flags allow 2ⁿ state combinations. Even if only 10 are valid, nothing in the code prevents the other combinations from occurring.
State machines define only valid states and make the rest structurally impossible. "Loading while simultaneously succeeded" cannot exist by design.
Declaring transitions explicitly makes the flow visible in code. No if-statement archaeology needed — which states exist and which events lead where is declared directly.
XState Visualizer auto-generates state diagrams from the code. The code is the documentation.
String union types are a useful intermediate step. Not every state problem needs XState. Match the tool to the complexity.
The difference between hunting for "why is isLoading still true?" and immediately knowing "the machine is in requesting, and the CONFIRMED event hasn't arrived yet" becomes obvious the first time you debug a real flow. Try it once on something with meaningful complexity and the boolean approach stops feeling like an option.