2026-02-28 · 8 min read
When to reach for a state machine in your UI
A practical look at modeling complex screens with explicit states instead of a pile of booleans, with examples from a real form.
You know the feeling. A form starts simple. Then validation lands. Then async submission. Then a “submitted but server is slow” state. Then “submitted, server said no, but we already cleared the input.” Suddenly your component has six booleans and they’re lying to you.
The booleans-of-doom pattern
Here’s the shape that always shows up around month three of a project:
function CheckoutForm() {
const [isLoading, setIsLoading] = useState(false);
const [isSubmitted, setIsSubmitted] = useState(false);
const [hasError, setHasError] = useState(false);
const [isValidating, setIsValidating] = useState(false);
const [isDirty, setIsDirty] = useState(false);
// ... and now: which combinations are valid?
}
Five booleans is 32 possible combinations. Maybe six of them are actual states the UI is supposed to show. The other 26 are bugs waiting to happen.
One state, many shapes
The fix is to collapse those flags into a single discriminated union where only valid combinations can exist:
type FormState =
| { status: "idle" }
| { status: "validating" }
| { status: "submitting" }
| { status: "success"; orderId: string }
| { status: "error"; message: string };
function CheckoutForm() {
const [state, setState] = useState<FormState>({ status: "idle" });
if (state.status === "success") {
return <OrderConfirmation orderId={state.orderId} />;
}
return (
<form onSubmit={handleSubmit}>
{/* ... */}
{state.status === "error" && <Error message={state.message} />}
<button disabled={state.status === "submitting"}>
{state.status === "submitting" ? "Sending..." : "Submit"}
</button>
</form>
);
}
Notice how orderId only exists in the success state, and message only
exists in the error state. TypeScript will refuse to let you read them in
the wrong place. The illegal states are gone.
When NOT to reach for it
State machines are not free. For a checkbox, they’re overkill. The rule of thumb I use:
- One or two booleans? Just use booleans.
- Three+ booleans that interact? Promote to a union.
- Async lifecycles? Always use a union.
The point isn’t to use the fanciest tool. It’s to make impossible states impossible to represent , and that’s worth the small upfront cost the moment your screen has more than one async thing happening.