State machines for UI: a practical Design Patterns guide
We've all shipped a UI where a user hit a race condition and saw both a loading spinner and an error message at the same time. State machines for UI eliminate these impossible states by making the valid transitions explicit. In this guide, we'll walk through how to model your UI as a state machine, from simple toggles to complex multi-step flows.
The boolean soup — isLoading, isError, isSuccess all true at once
Most teams start with independent boolean flags:
interface FormState {
isLoading: boolean;
isError: boolean;
isSuccess: boolean;
errorMessage?: string;
data?: unknown;
}
const [state, setState] = useState<FormState>({
isLoading: false,
isError: false,
isSuccess: false,
});
This approach feels natural at first. You set isLoading: true when a request starts, then conditionally set isSuccess: true or isError: true when it completes. But real codebases get messy. A race condition, a forgotten flag reset, or a timing bug can leave you with { isLoading: true, isError: true, isSuccess: true }. Your UI then renders all three states at once or, worse, crashes because your handlers assume only one is true.
We've seen this in production ride-hailing systems where a payment retry fired while the initial request was still pending. Both succeeded, both set their own success flags, and the system double-charged users. In e-commerce, a network flake caused carts to show "item removed" and "quantity updated" simultaneously, confusing customers about what actually happened.
The root problem: boolean flags don't capture the constraint that only one outcome should be active at a time.
Explicit states — idle → loading → success | error
A state machine uses a union type to represent every valid configuration the UI can be in. Only one state is active at any moment.
type FormState =
| { status: 'idle' }
| { status: 'loading' }
| { status: 'success'; data: User }
| { status: 'error'; message: string };
const [state, setState] = useState<FormState>({ status: 'idle' });
// Usage
if (state.status === 'loading') {
return <Spinner />;
}
if (state.status === 'success') {
return <p>User: {state.data.name}</p>;
}
if (state.status === 'error') {
return <p>Error: {state.message}</p>;
}
return <button>Fetch User</button>;
Now the TypeScript compiler enforces that state.data only exists when status === 'success'. If you try to access state.data in the error branch, you'll get a type error. Impossible states become literally impossible to represent.
Consider a sign-up flow in a SaaS platform. With booleans, you might accidentally show both "Email already exists" and "Sign-up successful" if a callback fired out of order. With explicit states:
type SignupState =
| { status: 'idle' }
| { status: 'validating-email' }
| { status: 'awaiting-email-verification' }
| { status: 'signup-complete'; userId: string }
| { status: 'error'; code: 'email-taken' | 'invalid-email' | 'network'; details?: string };
// The compiler ensures you handle each case distinctly
switch (state.status) {
case 'awaiting-email-verification':
return <EmailVerificationUI />;
case 'error':
if (state.code === 'email-taken') {
return <p>{state.details}</p>;
}
// ... other error codes
}
Each state is a cohesive snapshot. The data shape matches what that state actually needs.
Transitions — Events move between states; invalid events are ignored
A state machine is only as good as its transitions. We define which states can move to which other states, and under what conditions.
type FormEvent =
| { type: 'FETCH_START' }
| { type: 'FETCH_SUCCESS'; data: User }
| { type: 'FETCH_ERROR'; message: string }
| { type: 'RESET' };
function formReducer(state: FormState, event: FormEvent): FormState {
switch (state.status) {
case 'idle':
if (event.type === 'FETCH_START') {
return { status: 'loading' };
}
return state;
case 'loading':
if (event.type === 'FETCH_SUCCESS') {
return { status: 'success', data: event.data };
}
if (event.type === 'FETCH_ERROR') {
return { status: 'error', message: event.message };
}
// Ignore other events (e.g., RESET) while loading
return state;
case 'success':
case 'error':
if (event.type === 'RESET') {
return { status: 'idle' };
}
return state;
default:
return state;
}
}
Notice the key principle: while loading, we only accept FETCH_SUCCESS or FETCH_ERROR. A RESET event is silently ignored. This prevents race conditions. If two requests fire and responses arrive out of order, the second response (which lands while we're in success) gets ignored because success doesn't transition back to loading without an explicit RESET first.
In a trading platform we worked on, an order cancellation request and a price update arrived simultaneously. With booleans, both tried to update the order state and left it corrupted. With state machines, the cancellation moved the order to cancelled, and any incoming price updates were ignored because cancelled has no transition to accept them. The order remained consistent.
Invalid transitions are simply not in the switch statement. No guarding logic needed—the type system and reducer structure prevent them from being reachable.
Tools — XState for complex flows, a reducer for simple ones
For straightforward flows (fetch, toggle, form submission), a useReducer hook and a union type are enough:
const [state, dispatch] = useReducer(formReducer, { status: 'idle' });
const handleFetch = async () => {
dispatch({ type: 'FETCH_START' });
try {
const data = await fetchUser();
dispatch({ type: 'FETCH_SUCCESS', data });
} catch (error) {
dispatch({ type: 'FETCH_ERROR', message: error.message });
}
};
For multi-step workflows, parallel states, guards, and actions, XState is a mature choice. It's used in production across e-commerce checkout flows, CRM onboarding, and WebSocket reconnection logic.
import { createMachine, assign } from 'xstate';
const checkoutMachine = createMachine({
initial: 'cart',
states: {
cart: {
on: {
CHECKOUT: 'shipping',
},
},
shipping: {
on: {
SUBMIT_SHIPPING: [
{
target: 'billing',
cond: (context) => context.address.valid,
},
],
BACK: 'cart',
},
},
billing: {
on: {
SUBMIT_PAYMENT: {
target: 'processing',
actions: assign({ cardToken: (_, event) => event.token }),
},
BACK: 'shipping',
},
},
processing: {
invoke: {
src: 'submitOrder',
onDone: { target: 'success', actions: assign({ orderId: (_, event) => event.data.id }) },
onError: { target: 'error', actions: assign({ error: (_, event) => event.data }) },
},
},
success: { type: 'final' },
error: {
on: { RETRY: 'processing' },
},
},
});
XState handles the shape well. It has built-in support for guards (conditions), side effects, delayed transitions, and hierarchical states. A state machine in XState is serializable and can be visualized with xstate/inspect, which helped us debug a ride-hailing app's surge pricing flow.
Pick based on complexity: boolean flags are error-prone, useReducer with union types handles most cases, and XState handles the rest without ceremony.
Common mistakes
- Storing data outside the state. Don't do
{ status: 'success' }and then look upuserDatafrom a separate variable. Bundle the data into the state. TypeScript will then remind you that data only exists in the success branch. - Over-treating state machines as documentation. A machine you never actually run isn't helping. Use a reducer, XState, or similar to enforce it at runtime. Code comments are not enough.
- Ignoring edge cases in transitions. What happens if a user navigates away mid-request? If the network drops? If they click the button twice? Map these out. Silently ignoring events (as in our reducer) is often the right choice, but be deliberate.
- Mixing UI state and domain logic state. A form's
isSubmittingis UI state. A payment'sisPaidis domain state and shouldn't be in the same machine. Keep them separate.
Key takeaways
- State machines for UI eliminate impossible states by using union types to represent only valid configurations.
- Explicit transitions ensure that only expected events are accepted in each state, preventing race conditions and logic bugs.
- Use
useReducerwith a switch statement for simple flows; graduate to XState when you need guards, parallel states, or orchestration. - Each state should carry only the data it needs, enforced by TypeScript.
- State machines make code easier to test: given a state and an event, the output is deterministic.






Comments
Loading comments…