REACT Contents

State Machines in React

Complex UI flows need explicit states and transitions. Model them with discriminated unions and reducers to prevent impossible states, race conditions, and inconsistent rendering across loading, error, and retry paths.

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.