State Machines in React
On this page
Why State Machines
- Flows like checkout, onboarding, editor, and multi step forms have many transitions.
- Booleans create contradictions and hidden edge cases.
- State machines make transitions explicit and testable.
Minimal Model
- Define a finite set of states with required data per state.
- Define events that trigger transitions.
- Reducer enforces valid transitions and data invariants.
Example: Discriminated Union Machine
type State =
| { status: "idle" }
| { status: "editing"; draft: string }
| { status: "saving"; draft: string }
| { status: "error"; draft: string; message: string }
| { status: "saved"; value: string };
type Event =
| { type: "START"; value: string }
| { type: "CHANGE"; value: string }
| { type: "SAVE" }
| { type: "SUCCESS"; value: string }
| { type: "FAIL"; message: string }
| { type: "RESET" };
function reduce(state: State, ev: Event): State {
switch (ev.type) {
case "START":
return { status: "editing", draft: ev.value };
case "CHANGE":
if (state.status === "editing" || state.status === "error") return { status: "editing", draft: ev.value };
return state;
case "SAVE":
if (state.status === "editing") return { status: "saving", draft: state.draft };
return state;
case "SUCCESS":
return { status: "saved", value: ev.value };
case "FAIL":
if (state.status === "saving") return { status: "error", draft: state.draft, message: ev.message };
return state;
case "RESET":
return { status: "idle" };
default: {
const _never: never = ev;
return state;
}
}
}
Async Orchestration
- Reducer stays pure. Async work happens in handlers or effects.
- Guard against double submit and out of order responses.
- Carry request metadata if retries exist.
Example: Orchestrating Save
function useEditorMachine(initial: string) {
const [state, dispatch] = React.useReducer(reduce, { status: "idle" } as State);
const start = React.useCallback(() => dispatch({ type: "START", value: initial }), [initial]);
const change = React.useCallback((v: string) => dispatch({ type: "CHANGE", value: v }), []);
const save = React.useCallback(async () => {
dispatch({ type: "SAVE" });
try {
// example request
const res = await fetch("/api/save", { method: "POST", body: "..." });
if (!res.ok) throw new Error("request failed");
dispatch({ type: "SUCCESS", value: "saved" });
} catch (e) {
dispatch({ type: "FAIL", message: String(e) });
}
}, []);
return { state, start, change, save };
}
Failure Modes
- Impossible states from boolean soup.
- Async races where late responses overwrite newer transitions.
- Retry loops without attempt caps or user control.
- State clearing on refetch causing flicker and lost drafts.
Operational Checklist
- All valid states are enumerated.
- Transitions are explicit and tested.
- Reducer is pure and exhaustive.
- Async is race safe and guarded.
- Error state includes enough context for retry.