DevLift
Back to Blog

React: The `useReducer` Pattern — Manage Complex State Without Redux

When useState leaves you with six interdependent state variables, useReducer gives you one coherent state machine — plus a reducer you can test without mounting a component.

Admin
August 2, 20265 min read0 views

React: The useReducer Pattern — Manage Complex State Without Redux

You're building a multi-step checkout form. Step one collects shipping info, step two is payment, step three is a review screen. Each step has its own loading and error state. Skipping a step or going back should preserve what the user typed.

Your component starts with one useState. Then two. Then six. You've got setIsLoading, setError, setStep, setShippingData, setPaymentData, setOrderId all sitting at the top of your component like a yard sale. When something goes wrong — say, submitting payment should also reset the error and set loading to true — you've got three separate setState calls that have to fire in the right order, hoping React batches them.

This is the footgun useReducer was built to defuse.

The Problem: Scattered, Interdependent State

Here's what the "too many useState" situation actually looks like:

function CheckoutFlow() {
  const [step, setStep] = useState<1 | 2 | 3>(1);
  const [isLoading, setIsLoading] = useState(false);
  const [error, setError] = useState<string | null>(null);
  const [shippingData, setShippingData] = useState<ShippingData | null>(null);
  const [paymentData, setPaymentData] = useState<PaymentData | null>(null);
  const [orderId, setOrderId] = useState<string | null>(null);
 
  async function handlePaymentSubmit(data: PaymentData) {
    setIsLoading(true);  // 1st update
    setError(null);       // 2nd update
    setPaymentData(data); // 3rd update
    try {
      const id = await submitOrder({ shipping: shippingData!, payment: data });
      setOrderId(id);     // 4th update
      setStep(3);         // 5th update
    } catch (err) {
      setError(getErrorMessage(err)); // 6th update
    } finally {
      setIsLoading(false); // 7th update
    }
  }
  // ...
}

Seven state updates across one event handler. React batches the ones that happen in the same task, so this costs fewer renders than it looks like — but the batch breaks at every await, so you still get several. And render count isn't really the problem here. The problem is cognitive load. Anyone reading this has to mentally simulate which state changes together and why. There's no single source of truth for "what's the current phase of this checkout?"

The deeper issue: these pieces of state are not independent. They change together based on events. That's exactly the signal to reach for useReducer.

The Pattern: Reducer + Discriminated Actions

useReducer takes two things: a pure reducer function and an initial state. It gives you back the current state and a dispatch function. You dispatch an action — a plain object describing what happened — and the reducer decides what state that produces.

First, model the state explicitly:

type CheckoutStep = 1 | 2 | 3;
 
interface CheckoutState {
  step: CheckoutStep;
  isLoading: boolean;
  error: string | null;
  shippingData: ShippingData | null;
  paymentData: PaymentData | null;
  orderId: string | null;
}
 
const initialState: CheckoutState = {
  step: 1,
  isLoading: false,
  error: null,
  shippingData: null,
  paymentData: null,
  orderId: null,
};

Then define the actions as a discriminated union. This is where TypeScript earns its keep — each action shape is unambiguous:

type CheckoutAction =
  | { type: 'SHIPPING_SUBMITTED'; payload: ShippingData }
  | { type: 'PAYMENT_STARTED'; payload: PaymentData }
  | { type: 'PAYMENT_SUCCEEDED'; orderId: string }
  | { type: 'PAYMENT_FAILED'; error: string }
  | { type: 'STEP_BACK' }
  | { type: 'RESET' };

Now the reducer — a plain function, no hooks, no React imports needed:

function checkoutReducer(
  state: CheckoutState,
  action: CheckoutAction
): CheckoutState {
  switch (action.type) {
    case 'SHIPPING_SUBMITTED':
      return { ...state, step: 2, shippingData: action.payload };
 
    case 'PAYMENT_STARTED':
      return {
        ...state,
        isLoading: true,
        error: null,
        paymentData: action.payload,
      };
 
    case 'PAYMENT_SUCCEEDED':
      return {
        ...state,
        isLoading: false,
        step: 3,
        orderId: action.orderId,
      };
 
    case 'PAYMENT_FAILED':
      return { ...state, isLoading: false, error: action.error };
 
    case 'STEP_BACK':
      return { ...state, step: Math.max(1, state.step - 1) as CheckoutStep };
 
    case 'RESET':
      return initialState;
 
    default:
      return state;
  }
}

And in the component:

function CheckoutFlow() {
  const [state, dispatch] = useReducer(checkoutReducer, initialState);
 
  async function handlePaymentSubmit(data: PaymentData) {
    dispatch({ type: 'PAYMENT_STARTED', payload: data });
    try {
      const id = await submitOrder({ shipping: state.shippingData!, payment: data });
      dispatch({ type: 'PAYMENT_SUCCEEDED', orderId: id });
    } catch (err) {
      dispatch({ type: 'PAYMENT_FAILED', error: getErrorMessage(err) });
    }
  }
 
  return (
    <>
      {state.step === 1 && (
        <ShippingForm
          onSubmit={(data) => dispatch({ type: 'SHIPPING_SUBMITTED', payload: data })}
        />
      )}
      {state.step === 2 && (
        <PaymentForm
          isLoading={state.isLoading}
          error={state.error}
          onSubmit={handlePaymentSubmit}
          onBack={() => dispatch({ type: 'STEP_BACK' })}
        />
      )}
      {state.step === 3 && <OrderConfirmation orderId={state.orderId!} />}
    </>
  );
}

The component is now almost mechanical. It reads state and dispatches events. All the what-comes-next logic lives in the reducer where it can be read, reasoned about, and tested without mounting a single component.

How It Flows

Rendering diagram...

The key insight: dispatch is stable across renders (React guarantees it, like setState). Verified on React 19.2.3 — collecting the dispatch identity across three renders yields exactly one distinct function. You can pass it to memoised children or list it in a dependency array without it ever invalidating anything.

Variations

Lazy Initialization

If computing the initial state is expensive — reading from localStorage, parsing a URL, doing a computation — pass a third init argument:

function init(savedData: Partial<CheckoutState> | null): CheckoutState {
  if (!savedData) return initialState;
  return { ...initialState, ...savedData };
}
 
const [state, dispatch] = useReducer(checkoutReducer, savedCheckout, init);

The init function runs once on mount, not on every render.

Object Map Instead of Switch

Some teams find large switch statements harder to navigate. You can replace them with a handler map:

// `Partial` matters: a total mapped type demands a handler for every action,
// so an object literal that only covers some of them fails to compile with
// "missing the following properties: PAYMENT_SUCCEEDED, PAYMENT_FAILED, ...".
// The runtime lookup below already falls back to the current state.
type ReducerHandlers = Partial<{
  [K in CheckoutAction['type']]: (
    state: CheckoutState,
    action: Extract<CheckoutAction, { type: K }>
  ) => CheckoutState;
}>;
 
const handlers: ReducerHandlers = {
  SHIPPING_SUBMITTED: (state, action) => ({
    ...state,
    step: 2,
    shippingData: action.payload,
  }),
  PAYMENT_STARTED: (state, action) => ({
    ...state,
    isLoading: true,
    error: null,
    paymentData: action.payload,
  }),
  // ...one entry per action type you actually handle
};
 
function checkoutReducer(
  state: CheckoutState,
  action: CheckoutAction
): CheckoutState {
  const handler = handlers[action.type] as (
    s: CheckoutState,
    a: CheckoutAction
  ) => CheckoutState;
  return handler ? handler(state, action) : state;
}

This is a style preference. The switch version is more familiar; the map version is easier to tree-shake or split across files for large reducers.

Pairing with Context for Lightweight Global State

When you need the same state across multiple components without installing Zustand or Redux, useReducer + useContext is a solid pattern:

const CheckoutStateContext = createContext<CheckoutState | null>(null);
const CheckoutDispatchContext = createContext<React.Dispatch<CheckoutAction> | null>(null);
 
export function CheckoutProvider({ children }: { children: React.ReactNode }) {
  const [state, dispatch] = useReducer(checkoutReducer, initialState);
  return (
    <CheckoutStateContext.Provider value={state}>
      <CheckoutDispatchContext.Provider value={dispatch}>
        {children}
      </CheckoutDispatchContext.Provider>
    </CheckoutStateContext.Provider>
  );
}
 
export function useCheckoutState() {
  const ctx = useContext(CheckoutStateContext);
  if (!ctx) throw new Error('useCheckoutState must be used within CheckoutProvider');
  return ctx;
}
 
export function useCheckoutDispatch() {
  const ctx = useContext(CheckoutDispatchContext);
  if (!ctx) throw new Error('useCheckoutDispatch must be used within CheckoutProvider');
  return ctx;
}

Splitting state and dispatch into two contexts is intentional. Components that only dispatch never re-render when state changes. Components that only read state don't carry a dispatch reference. It's a small but meaningful optimization at scale.

Immutable Updates with Immer

Spread syntax works fine for shallow objects, but nested state gets ugly fast:

// Without Immer
case 'UPDATE_ITEM_QTY':
  return {
    ...state,
    cart: {
      ...state.cart,
      items: state.cart.items.map((item) =>
        item.id === action.id
          ? { ...item, quantity: action.quantity }
          : item
      ),
    },
  };

With Immer:

import { produce } from 'immer';
 
// Annotate `draft` — the curried form of produce can't infer it, and without
// the annotation every access inside is implicitly `any` under `strict`.
const cartReducer = produce((draft: CartState, action: CartAction) => {
  switch (action.type) {
    case 'UPDATE_ITEM_QTY': {
      const item = draft.cart.items.find((i) => i.id === action.id);
      if (item) item.quantity = action.quantity;
      break;
    }
  }
});

The curried produce(recipe) returns (state, action) => nextState, which is exactly the useReducer signature — so useReducer(cartReducer, initialState) works with no adapter. You write mutating code; Immer produces the new immutable state.

Testing the Reducer

This is where useReducer quietly shines over useState. The reducer is just a function. Test it without React, without mounting, without act():

import { describe, it, expect } from 'vitest';
import { checkoutReducer, initialState } from './checkoutReducer';
 
describe('checkoutReducer', () => {
  it('moves to step 2 on SHIPPING_SUBMITTED', () => {
    const action = { type: 'SHIPPING_SUBMITTED' as const, payload: mockShippingData };
    const next = checkoutReducer(initialState, action);
    expect(next.step).toBe(2);
    expect(next.shippingData).toEqual(mockShippingData);
  });
 
  it('clears error and sets loading on PAYMENT_STARTED', () => {
    const stateWithError = { ...initialState, error: 'Previous error' };
    const action = { type: 'PAYMENT_STARTED' as const, payload: mockPaymentData };
    const next = checkoutReducer(stateWithError, action);
    expect(next.isLoading).toBe(true);
    expect(next.error).toBeNull();
  });
});

Pure functions are a gift to test suites. No mocking, no setup, just input → output.

When NOT to Use useReducer

useReducer adds ceremony. Don't add it where useState is perfectly fine.

Keep useState for:

  • Single boolean flags (isOpen, isLoading)
  • Values that change independently with no relationship to other state
  • Simple counters or toggles where the "transition" is obvious from the update call

Avoid useReducer when:

  • You find yourself writing a reducer with a single action type — that's just useState with extra steps
  • Your "state" is really just derived data that should be useMemo
  • You need async operations inside the reducer — reducers must be synchronous and pure. Keep async logic in the event handler and dispatch the result

Don't mistake it for a Redux replacement. If you need middleware, time-travel debugging, DevTools integration, or sharing state across route boundaries, reach for Zustand or Redux Toolkit instead. useReducer + Context is great for component subtrees; it doesn't scale to large apps the way a dedicated store does — and that's fine.

A good smell test: if you can give every possible state transition a name (like PAYMENT_STARTED or STEP_BACK), you have a reducer. If you can't name them, your state is probably too granular or not actually related.

The Real Win: Naming Things

useState forces you to express what changes (setIsLoading(true)). useReducer forces you to express what happened (PAYMENT_STARTED). That shift from imperative to declarative state transitions is the actual value of the pattern.

When you read dispatch({ type: 'PAYMENT_STARTED', payload: data }) you understand the intent without reading the reducer. When you read setIsLoading(true); setError(null); setPaymentData(data) you have to mentally simulate what it means together.

As state machines go, useReducer is a lightweight one — no formal FSM library, no complex configuration. Just a function that takes the current state and an event and tells you what comes next. For most component-level complexity, that's all you need.

Comments (0)

No comments yet. Be the first to share your thoughts!

Related Articles

Stop casting with `as` and `!`. Discriminated unions give TypeScript enough information to narrow types automatically — making impossible states unrepresentable and exhaustiveness checking free.
AdminAugust 2, 20265 min read
TypeScript ships with 15+ utility types that let you derive new types from existing ones. Here's the full toolkit with the production patterns you'll actually reach for.
AdminAugust 2, 20265 min read
TypeScript is the default for serious JavaScript work now — but the real question is when the type overhead pays off, and which parts of your codebase genuinely don't need it.
AdminAugust 2, 20268 min read