6 min read
0%

Reducer Patterns with useReducer

Back to Blog
Reducer Patterns with useReducer

Reducer Patterns with useReducer

useReducer is a state management hook for complex state logic — when next state depends on previous state across multiple operations, or when updates benefit from explicit named transitions.

Basic Usage

const initialState = { count: 0 };

function reducer(state, action) {
  switch (action.type) {
    case 'increment':
      return { count: state.count + 1 };
    case 'decrement':
      return { count: state.count - 1 };
    case 'reset':
      return initialState;
    default:
      throw new Error(`Unknown action: ${action.type}`);
  }
}

function Counter() {
  const [state, dispatch] = useReducer(reducer, initialState);
  return (
    <>
      <p>{state.count}</p>
      <button onClick={() => dispatch({ type: 'increment' })}>+</button>
      <button onClick={() => dispatch({ type: 'decrement' })}>-</button>
    </>
  );
}

When useReducer Over useState

  • Multiple related state fields that update together
  • Next state depends on previous state in complex ways
  • State transitions have names that make the code self-documenting
  • You want to test state logic independently of the component
// useState version — hard to reason about
const [loading, setLoading] = useState(false);
const [data, setData] = useState(null);
const [error, setError] = useState(null);

// useReducer version — explicit, consistent transitions
const [state, dispatch] = useReducer(fetchReducer, {
  status: 'idle', data: null, error: null,
});
// state.status is always 'idle' | 'loading' | 'success' | 'error'

Action Creators

const actions = {
  increment: () => ({ type: 'increment' }),
  addItem: (item) => ({ type: 'add_item', payload: item }),
};

dispatch(actions.addItem(newItem));

Immer Integration

import produce from 'immer';

const reducer = produce((draft, action) => {
  switch (action.type) {
    case 'add_item':
      draft.items.push(action.payload);
      break;
    case 'remove_item':
      draft.items = draft.items.filter(i => i.id !== action.payload);
      break;
  }
});

Lazy Initialization

const [state, dispatch] = useReducer(reducer, initialArg, init);
// init(initialArg) called once — defers expensive computation

Sharing Dispatch via Context

const DispatchContext = createContext();

function AppProvider({ children }) {
  const [state, dispatch] = useReducer(reducer, initialState);
  return (
    <DispatchContext.Provider value={dispatch}>
      <StateContext.Provider value={state}>
        {children}
      </StateContext.Provider>
    </DispatchContext.Provider>
  );
}

// Anywhere in the tree
const dispatch = useContext(DispatchContext);
dispatch({ type: 'update_user', payload: user });

dispatch is stable — components that only dispatch don’t re-render when state changes.

Testing Reducers

Reducers are pure functions — trivial to unit test:

test('increment increases count', () => {
  const state = reducer({ count: 5 }, { type: 'increment' });
  expect(state.count).toBe(6);
});

Canvas is not supported in your browser