Skip to content
Codeloom
React

Building State Machines with useReducer in React

Implement finite state machines using only React's useReducer hook: explicit states, guarded transitions, and elimination of impossible UI states without libraries.

·8 min read · By Codeloom
Intermediate 11 min read

What you'll learn

  • How to model UI states as a finite state machine
  • Implementing state machines with useReducer alone
  • Guarded transitions that prevent impossible states
  • Patterns for async operations as state machines
  • When a useReducer state machine beats a library

Prerequisites

None — this post is self-contained.

Boolean flags are the root cause of most UI bugs. A component with isLoading, isError, hasData, and isRetrying has sixteen possible combinations, most of which are nonsense. A state machine eliminates this by declaring exactly which states exist and which transitions are allowed. You do not need a library to build one. React’s useReducer is enough.

The problem with boolean state

Consider a data fetching component with flags:

const [isLoading, setIsLoading] = useState(false);
const [isError, setIsError] = useState(false);
const [data, setData] = useState(null);
const [isRetrying, setIsRetrying] = useState(false);

Can isLoading and isError both be true? What does isRetrying mean if isError is false? The code does not enforce any rules. Every flag is independent, so every combination is technically possible. Bugs hide in the impossible combinations that your rendering logic does not handle.

The state machine alternative

A state machine declares a finite set of states and the events that transition between them. At any moment, the machine is in exactly one state.

For data fetching, the states are:

  • idle - nothing has happened yet
  • loading - fetch is in progress
  • success - data is available
  • error - fetch failed
  • retrying - retrying after failure

The transitions are:

  • idle -> FETCH -> loading
  • loading -> RESOLVE -> success
  • loading -> REJECT -> error
  • error -> RETRY -> retrying
  • retrying -> RESOLVE -> success
  • retrying -> REJECT -> error

There is no way to go from idle to error directly. There is no state where you are both loading and in error. The machine prevents impossible states by design.

Implementing with useReducer

The reducer function is the state machine. It takes the current state and an event, and returns the next state based on the transition rules.

type State =
  | { status: 'idle' }
  | { status: 'loading' }
  | { status: 'success'; data: unknown }
  | { status: 'error'; error: string }
  | { status: 'retrying'; attempt: number };

type Event =
  | { type: 'FETCH' }
  | { type: 'RESOLVE'; data: unknown }
  | { type: 'REJECT'; error: string }
  | { type: 'RETRY' }
  | { type: 'RESET' };

function fetchReducer(state: State, event: Event): State {
  switch (state.status) {
    case 'idle':
      if (event.type === 'FETCH') return { status: 'loading' };
      return state;

    case 'loading':
      if (event.type === 'RESOLVE') return { status: 'success', data: event.data };
      if (event.type === 'REJECT') return { status: 'error', error: event.error };
      return state;

    case 'success':
      if (event.type === 'FETCH') return { status: 'loading' };
      if (event.type === 'RESET') return { status: 'idle' };
      return state;

    case 'error':
      if (event.type === 'RETRY') return { status: 'retrying', attempt: 1 };
      if (event.type === 'RESET') return { status: 'idle' };
      return state;

    case 'retrying':
      if (event.type === 'RESOLVE') return { status: 'success', data: event.data };
      if (event.type === 'REJECT') {
        if (state.attempt >= 3) return { status: 'error', error: event.error };
        return { status: 'retrying', attempt: state.attempt + 1 };
      }
      return state;

    default:
      return state;
  }
}

The structure is a nested switch: first on the current state, then on the event. Unhandled events return the current state unchanged. This is the guard mechanism. Dispatching RETRY from the idle state does nothing because there is no transition defined for it.

Using the state machine in a component

import { useReducer, useCallback } from 'react';

function UserProfile({ userId }: { userId: string }) {
  const [state, dispatch] = useReducer(fetchReducer, { status: 'idle' });

  const fetchUser = useCallback(async () => {
    dispatch({ type: 'FETCH' });
    try {
      const response = await fetch(`/api/users/${userId}`);
      if (!response.ok) throw new Error('Failed to fetch');
      const data = await response.json();
      dispatch({ type: 'RESOLVE', data });
    } catch (err) {
      dispatch({ type: 'REJECT', error: (err as Error).message });
    }
  }, [userId]);

  const retry = useCallback(async () => {
    dispatch({ type: 'RETRY' });
    try {
      const response = await fetch(`/api/users/${userId}`);
      if (!response.ok) throw new Error('Failed to fetch');
      const data = await response.json();
      dispatch({ type: 'RESOLVE', data });
    } catch (err) {
      dispatch({ type: 'REJECT', error: (err as Error).message });
    }
  }, [userId]);

  switch (state.status) {
    case 'idle':
      return <button onClick={fetchUser}>Load Profile</button>;
    case 'loading':
      return <div>Loading...</div>;
    case 'success':
      return <div>{JSON.stringify(state.data)}</div>;
    case 'error':
      return (
        <div>
          <p>Error: {state.error}</p>
          <button onClick={retry}>Retry</button>
        </div>
      );
    case 'retrying':
      return <div>Retrying (attempt {state.attempt})...</div>;
  }
}

The rendering logic uses a switch on state.status. TypeScript narrows the type in each branch, so state.data is only accessible when status is 'success'. No boolean checks needed.

A form submission state machine

Forms are another place where state machines shine. A form can be idle, validating, submitting, submitted, or in error. Each state has different UI requirements.

type FormState =
  | { status: 'editing'; values: FormValues; errors: Record<string, string> }
  | { status: 'submitting'; values: FormValues }
  | { status: 'submitted'; result: SubmitResult }
  | { status: 'error'; values: FormValues; error: string };

type FormEvent =
  | { type: 'UPDATE_FIELD'; field: string; value: string }
  | { type: 'SUBMIT' }
  | { type: 'SUBMIT_SUCCESS'; result: SubmitResult }
  | { type: 'SUBMIT_ERROR'; error: string }
  | { type: 'EDIT_AGAIN' };

function formReducer(state: FormState, event: FormEvent): FormState {
  switch (state.status) {
    case 'editing':
      if (event.type === 'UPDATE_FIELD') {
        return {
          ...state,
          values: { ...state.values, [event.field]: event.value },
          errors: { ...state.errors, [event.field]: '' },
        };
      }
      if (event.type === 'SUBMIT') {
        const errors = validate(state.values);
        if (Object.keys(errors).length > 0) {
          return { ...state, errors };
        }
        return { status: 'submitting', values: state.values };
      }
      return state;

    case 'submitting':
      if (event.type === 'SUBMIT_SUCCESS') {
        return { status: 'submitted', result: event.result };
      }
      if (event.type === 'SUBMIT_ERROR') {
        return { status: 'error', values: state.values, error: event.error };
      }
      return state;

    case 'submitted':
      if (event.type === 'EDIT_AGAIN') {
        return { status: 'editing', values: emptyValues, errors: {} };
      }
      return state;

    case 'error':
      if (event.type === 'SUBMIT') {
        return { status: 'submitting', values: state.values };
      }
      if (event.type === 'UPDATE_FIELD') {
        return {
          status: 'editing',
          values: { ...state.values, [event.field]: event.value },
          errors: {},
        };
      }
      return state;

    default:
      return state;
  }
}

The machine ensures you cannot submit while already submitting (the event is ignored in the submitting state). You cannot edit fields while submitting. The error state lets you either retry the submission or go back to editing. Each transition is explicit.

A multi-step wizard

State machines handle multi-step flows naturally. Each step is a state. Navigation events move between steps.

type WizardState =
  | { step: 'personal'; data: Partial<WizardData> }
  | { step: 'address'; data: Partial<WizardData> }
  | { step: 'payment'; data: Partial<WizardData> }
  | { step: 'review'; data: WizardData }
  | { step: 'complete'; orderId: string };

type WizardEvent =
  | { type: 'NEXT'; data: Partial<WizardData> }
  | { type: 'BACK' }
  | { type: 'CONFIRM'; orderId: string };

function wizardReducer(state: WizardState, event: WizardEvent): WizardState {
  switch (state.step) {
    case 'personal':
      if (event.type === 'NEXT') {
        return { step: 'address', data: { ...state.data, ...event.data } };
      }
      return state;

    case 'address':
      if (event.type === 'NEXT') {
        return { step: 'payment', data: { ...state.data, ...event.data } };
      }
      if (event.type === 'BACK') {
        return { step: 'personal', data: state.data };
      }
      return state;

    case 'payment':
      if (event.type === 'NEXT') {
        return { step: 'review', data: { ...state.data, ...event.data } as WizardData };
      }
      if (event.type === 'BACK') {
        return { step: 'address', data: state.data };
      }
      return state;

    case 'review':
      if (event.type === 'CONFIRM') {
        return { step: 'complete', orderId: event.orderId };
      }
      if (event.type === 'BACK') {
        return { step: 'payment', data: state.data };
      }
      return state;

    default:
      return state;
  }
}

You cannot skip steps. You cannot go back from the complete state. The transitions enforce the business rules.

When to use a library instead

A useReducer state machine works well when the state machine is small (under 10 states), the transitions are straightforward, you do not need visualization or debugging tools, and you want zero dependencies.

Reach for a library like XState when the machine has many states with complex transitions, you need parallel states (multiple machines running simultaneously), you want a visual editor or state chart debugging, or you need delayed transitions and invoked services. XState provides these features out of the box. Reimplementing them in a useReducer is possible but wasteful.

For most component-level state, useReducer is the right tool. For application-level orchestration, a dedicated library earns its weight.