React State Machines: Replace Boolean Flags With Explicit UI States
Learn how explicit state machines make React interfaces easier to reason about, test, and extend than collections of interacting boolean flags.
A familiar React bug starts with a harmless-looking feature request: show a spinner while a form submits, display a success message afterward, and let the user retry if the request fails. A few boolean flags seem sufficient: isSubmitting, isSuccess, and hasError. A month later, the component can render contradictory states, accept clicks while a request is still running, or display an old error after a successful retry.
The problem is not that booleans are bad. The problem is that independent flags describe combinations your interface may never be allowed to enter. When the number of flags grows, the component's real state becomes implicit. A state machine makes it explicit by listing valid states and the events that move the UI between them.
Why boolean flags become difficult to trust
Suppose a data-fetching component has isLoading, hasData, hasError, and isRefreshing. Each variable is easy to understand in isolation. Together they permit sixteen theoretical combinations, although the product may support only a handful. What should happen when isLoading and hasError are both true? Or when hasData and hasError are both true? Sometimes those combinations are intentional, such as showing stale data alongside a refresh error. More often, they are accidental leftovers from asynchronous timing.
// Four independent flags: sixteen combinations, only a few of them legal.
const [isLoading, setIsLoading] = useState(false);
const [hasData, setHasData] = useState(false);
const [hasError, setHasError] = useState(false);
const [isRefreshing, setIsRefreshing] = useState(false);
// Nothing here stops two mutually exclusive branches from rendering at once.
return (
<>
{hasError && <ErrorBanner onRetry={refetch} />}
{hasData && <Table rows={rows} />}
{isLoading && <Spinner />}
</>
);- The component can render two mutually exclusive messages at once.
- An event handler can run in a state where it should be disabled.
- A later response can overwrite the result of a newer request.
- A reset path can clear one flag while leaving another stale.
- Tests need to cover many combinations that are difficult to name and explain.
Boolean flags also hide transitions. Reading isSubmitting tells you what is true now, but not whether the form arrived there from idle, validation failure, or a previous submission. That history matters when deciding whether to clear an error, preserve entered data, or ignore a duplicate event.
Model the UI as states and events
A state-machine model separates two concepts: the current state and the event that may cause a transition. For a form, the states might be idle, submitting, succeeded, and failed. Events might be SUBMIT, RESOLVE, REJECT, and RESET. The key design decision is that each state defines which events are meaningful.
| State | Allowed event | Next state | Typical UI |
|---|---|---|---|
| idle | SUBMIT | submitting | Editable form and submit button |
| submitting | RESOLVE | succeeded | Disabled form and progress indicator |
| submitting | REJECT | failed | Disabled form until the request finishes |
| failed | SUBMIT | submitting | Error message with a retry action |
| succeeded | RESET | idle | Success message or a cleared form |
This structure prevents impossible transitions. A RESOLVE event received while the form is idle has no effect. A second SUBMIT event received while submitting can be ignored or handled deliberately. A RESET event can be allowed from succeeded and failed without needing to remember which flags must be cleared.
Represent the model in TypeScript
For small components, a discriminated union is often enough. It gives the compiler a precise description of the valid states and makes stale data harder to carry accidentally. State-specific fields can be attached only where they make sense: an error belongs to failed, while a response belongs to succeeded.
type FormState =
| { status: 'idle' }
| { status: 'submitting'; requestId: number }
| { status: 'succeeded'; response: SubmitResponse }
| { status: 'failed'; error: string };
type FormEvent =
| { type: 'SUBMIT'; requestId: number }
| { type: 'RESOLVE'; requestId: number; response: SubmitResponse }
| { type: 'REJECT'; requestId: number; error: string }
| { type: 'RESET' };The reducer should be the only place that decides how events change state. Keeping transitions in one pure function makes the behavior readable, independently testable, and less dependent on the order in which React event handlers happen to run.
function formReducer(state: FormState, event: FormEvent): FormState {
switch (state.status) {
case 'idle':
case 'failed':
// Only SUBMIT is meaningful here; every other event is ignored.
return event.type === 'SUBMIT' ? { status: 'submitting', requestId: event.requestId } : state;
case 'submitting':
// A result that belongs to an older request is no longer the active one.
if (event.type === 'RESOLVE' && event.requestId === state.requestId)
return { status: 'succeeded', response: event.response };
if (event.type === 'REJECT' && event.requestId === state.requestId)
return { status: 'failed', error: event.error };
return state;
case 'succeeded':
return event.type === 'RESET' ? { status: 'idle' } : state;
}
}Keep asynchronous work separate from transition rules
A reducer can describe the lifecycle of a request, but it should not perform the request itself. Trigger the asynchronous operation in an event handler, effect, or dedicated data-fetching layer, then dispatch a result event when the operation completes. This separation keeps side effects outside the transition logic.
There is one important race to handle: an older request can finish after a newer one. A state machine does not solve request cancellation automatically. Add a request identifier, AbortController, or a data-fetching library that tracks request freshness. The transition should accept a response only when it belongs to the active request.
const [state, dispatch] = useReducer(formReducer, { status: 'idle' });
const lastRequestId = useRef(0);
async function handleSubmit(values: FormValues) {
if (state.status === 'submitting') return; // a duplicate click changes nothing
const requestId = ++lastRequestId.current;
dispatch({ type: 'SUBMIT', requestId });
try {
const response = await submitForm(values);
dispatch({ type: 'RESOLVE', requestId, response });
} catch (error) {
dispatch({ type: 'REJECT', requestId, error: String(error) });
}
}- Create an identifier when entering the loading state.
- Attach that identifier to the request and its result event.
- Ignore a result whose identifier is no longer active.
- Abort work that no longer matters when the component or request changes.
- Test both completion orders: newest-first and oldest-first.
Do not turn every component into a framework
A state machine is useful when a component has mutually exclusive modes, asynchronous transitions, retries, permissions, or several actions whose validity depends on the current mode. It is unnecessary for independent presentation preferences such as isSidebarOpen or isMuted when those values do not constrain one another.
Start with a union and reducer before reaching for a state-machine library. A library becomes worthwhile when the graph is shared across screens, transitions need visualization, parallel states are involved, or the team benefits from standardized tooling. The goal is not to introduce more abstractions; it is to make the existing domain rules visible.
A practical review checklist
- Can you name every valid state in plain language?
- Can the UI enter a contradictory combination of flags?
- Which events are ignored in each state?
- Where are request races, cancellation, and retries handled?
- Are state-specific values stored only in the states that use them?
- Can the transition logic be tested without rendering the component?
- Would a new engineer understand the lifecycle without tracing several effects?
The biggest benefit is not shorter code. It is a smaller reasoning surface. When the interface can be in one of six named states instead of an undocumented combination of flags, product behavior becomes easier to discuss, edge cases become easier to test, and changes are less likely to create a new impossible state.
This approach is especially valuable in complex web applications where forms, background refreshes, optimistic updates, and permissions interact. During a frontend architecture review or legacy modernization effort, converting the most fragile flag-based workflows first can improve reliability without requiring a rewrite of the entire application.
Frequently asked questions
Do I need a state-machine library for every React state flow?
No. A TypeScript discriminated union and a reducer are enough for many local workflows. Use a library when the state graph is large, shared, parallel, or benefits from visualization and standardized tooling.
Are boolean flags always a bad choice?
No. Independent flags are appropriate when values do not describe mutually exclusive modes or constrain one another. The warning sign is a growing set of flags whose valid combinations are difficult to explain.
Can a state machine prevent race conditions in requests?
It can make request states and accepted events explicit, but it does not cancel or identify asynchronous work automatically. Use request identifiers, AbortController, or a data-fetching tool that handles freshness.