useReducer Patterns for Real State

Model non-trivial state as explicit transitions, and let the reducer be the single source of truth.

useReducer shines the moment your state has rules: fields that must change together, transitions that are only valid from certain states, or updates that depend on the previous value. Instead of scattering setX calls, you name each event and handle it in one pure function.

Think in events, not setters

Actions describe what happened ('submitted', 'field_changed', 'retry'), not how state changes. The reducer owns the how. This reads like a log of your app's behavior and is trivially testable — it is just reducer(state, action), no React required.

A tiny fetch machine

function reducer(state, action) {
  switch (action.type) {
    case 'start':   return { status: 'loading', data: null, error: null };
    case 'success': return { status: 'done', data: action.data, error: null };
    case 'fail':    return { status: 'error', data: null, error: action.error };
    default:        return state;
  }
}

Now impossible states are impossible: you can never be loading and hold an error at the same time, because no action produces that.

Handy patterns

  • Lazy init — pass a third argument, an init function, to compute the initial state (e.g. from props) without recomputing every render.
  • Dispatch is stable — React guarantees the dispatch identity never changes, so it is safe to pass down or drop into dependency arrays with no useCallback.
  • Reducer + Context — put state and dispatch in context to give a whole subtree a mini store.

Example

Example · javascript
import { useReducer } from 'react';

const initial = { items: [], draft: '', filter: 'all' };

function reducer(state, action) {
  switch (action.type) {
    case 'draft_changed':
      return { ...state, draft: action.value };
    case 'added': {
      const text = state.draft.trim();
      if (!text) return state; // guard invalid transition
      return {
        ...state,
        draft: '',
        items: [...state.items, { id: crypto.randomUUID(), text, done: false }],
      };
    }
    case 'toggled':
      return {
        ...state,
        items: state.items.map((i) =>
          i.id === action.id ? { ...i, done: !i.done } : i
        ),
      };
    case 'filter_changed':
      return { ...state, filter: action.value };
    default:
      return state;
  }
}

function Todos() {
  const [state, dispatch] = useReducer(reducer, initial);
  const visible = state.items.filter((i) =>
    state.filter === 'all' ? true : state.filter === 'done' ? i.done : !i.done
  );
  return (
    <div>
      <input
        value={state.draft}
        onChange={(e) => dispatch({ type: 'draft_changed', value: e.target.value })}
        onKeyDown={(e) => e.key === 'Enter' && dispatch({ type: 'added' })}
      />
      <ul>
        {visible.map((i) => (
          <li key={i.id} onClick={() => dispatch({ type: 'toggled', id: i.id })}>
            {i.done ? 'x' : 'o'} {i.text}
          </li>
        ))}
      </ul>
    </div>
  );
}

When to use it

  • A checkout wizard uses a useReducer to model states like 'collecting_shipping', 'validating_payment', and 'confirmed' so illegal transitions are impossible by construction.
  • A real-time collaboration editor dispatches INSERT_TEXT, DELETE_RANGE, and SET_CURSOR actions to a reducer, making the entire operation log inspectable and replayable.
  • A form with cross-field validation uses a reducer to reset dependent fields automatically when a parent field changes — logic that would require multiple useState calls to coordinate.

More examples

State machine with useReducer

Models fetch states as a finite state machine in a reducer so only valid transitions are possible.

Example · jsx
import { useReducer } from 'react';

const TRANSITIONS = {
  idle:    { SUBMIT: 'loading' },
  loading: { SUCCESS: 'success', FAIL: 'error' },
  success: { RESET: 'idle' },
  error:   { RESET: 'idle' },
};

function reducer(state, action) {
  const next = TRANSITIONS[state.status]?.[action.type];
  if (!next) return state;
  return { status: next, data: action.data ?? state.data, error: action.error ?? null };
}

function FetchButton({ onFetch }) {
  const [state, dispatch] = useReducer(reducer, { status: 'idle', data: null, error: null });
  return (
    <button
      onClick={async () => {
        dispatch({ type: 'SUBMIT' });
        try { dispatch({ type: 'SUCCESS', data: await onFetch() }); }
        catch (e) { dispatch({ type: 'FAIL', error: e.message }); }
      }}
      disabled={state.status === 'loading'}
    >
      {state.status === 'loading' ? 'Loading...' : 'Fetch'}
    </button>
  );
}

Reducer with action creators

Centralises cart transitions in a reducer and provides action creators so callers never hardcode action objects.

Example · js
// Action creators give transitions meaningful names and type safety
export const actions = {
  addItem: item => ({ type: 'ADD_ITEM', item }),
  removeItem: id => ({ type: 'REMOVE_ITEM', id }),
  setQuantity: (id, qty) => ({ type: 'SET_QTY', id, qty }),
};

export function cartReducer(state, action) {
  switch (action.type) {
    case 'ADD_ITEM':
      return [...state, { ...action.item, qty: 1 }];
    case 'REMOVE_ITEM':
      return state.filter(i => i.id !== action.id);
    case 'SET_QTY':
      return state.map(i => i.id === action.id ? { ...i, qty: action.qty } : i);
    default:
      return state;
  }
}

Immer-style immutable update

Uses Immer's produce as the reducer so deep state mutations are written as direct assignments, not spreads.

Example · jsx
import { useReducer } from 'react';
import { produce } from 'immer';

const reducer = produce((draft, action) => {
  switch (action.type) {
    case 'TOGGLE_DONE':
      const task = draft.find(t => t.id === action.id);
      if (task) task.done = !task.done;
      break;
    case 'ADD':
      draft.push({ id: Date.now(), text: action.text, done: false });
      break;
  }
});

function TodoList() {
  const [tasks, dispatch] = useReducer(reducer, []);
  return <ul>{tasks.map(t => <li key={t.id}>{t.text}</li>)}</ul>;
}

Discussion

  • Be the first to comment on this lesson.