useReducer Hook

Difficulty: Intermediate

useReducer is a React hook for managing complex state logic. It follows the reducer pattern popularized by Redux: state transitions are described by action objects and processed by a pure reducer function. useReducer is an alternative to useState that is preferable when state logic involves multiple sub-values, when the next state depends on the previous state, or when you need to pass dispatch down through many levels.

useReducer accepts three arguments: a reducer function, an initial state, and an optional initializer function. It returns the current state and a dispatch function. The reducer takes the current state and an action, and returns the new state. Actions are plain objects, conventionally with a 'type' property that identifies the action and optional payload data. The dispatch function is stable across renders (like setState from useState), so it can safely be passed through context without causing re-renders.

The reducer function must be pure - given the same state and action, it must always return the same new state. It should not mutate the existing state, perform side effects, or call other hooks. Instead, it returns a new state object. This purity makes reducers easy to test in isolation: just call the function with a state and action and assert the return value.

A major advantage of useReducer is centralizing state transitions. With useState, state update logic is scattered across event handlers throughout the component. With useReducer, all state transitions are defined in one place - the reducer function. This makes it easier to understand how state can change, debug incorrect states, and add new state transitions without hunting through the component for every setState call.

useReducer combined with useContext creates a powerful state management pattern. The dispatch function is stable and can be passed through context without memoization concerns. Child components dispatch actions to update global state, and the reducer processes these actions deterministically. This pattern handles moderate-complexity state management needs without requiring external libraries.

Code examples

Basic useReducer for a counter

function counterReducer(state, action) {
  switch (action.type) {
    case 'increment':
      return { count: state.count + 1 };
    case 'decrement':
      return { count: state.count - 1 };
    case 'reset':
      return { count: 0 };
    default:
      return state;
  }
}

function Counter() {
  const [state, dispatch] = React.useReducer(counterReducer, { count: 0 });

  console.log('Count:', state.count);

  dispatch({ type: 'increment' });
  console.log('After increment:', state.count);
  // Note: state doesn't update synchronously in the same render
  // The new value appears on the next render

  return null;
}

// Demonstrating the reducer directly
console.log('Reducer test:');
let state = { count: 0 };
state = counterReducer(state, { type: 'increment' });
console.log('After increment:', state.count);
state = counterReducer(state, { type: 'increment' });
console.log('After increment:', state.count);
state = counterReducer(state, { type: 'reset' });
console.log('After reset:', state.count);

The reducer is a pure function that computes the next state from the current state and an action. Each action type maps to a specific state transition. The reducer can be tested independently of React.

useReducer with complex state

function formReducer(state, action) {
  switch (action.type) {
    case 'SET_FIELD':
      return { ...state, [action.field]: action.value };
    case 'SET_ERROR':
      return { ...state, errors: { ...state.errors, [action.field]: action.message } };
    case 'CLEAR_ERRORS':
      return { ...state, errors: {} };
    case 'SUBMIT':
      return { ...state, isSubmitting: true };
    default:
      return state;
  }
}

const initialState = {
  name: '',
  email: '',
  errors: {},
  isSubmitting: false
};

// Test the reducer
let state = initialState;
console.log('Initial:', JSON.stringify(state));

state = formReducer(state, { type: 'SET_FIELD', field: 'name', value: 'Alice' });
console.log('Name set:', state.name);

state = formReducer(state, { type: 'SET_FIELD', field: 'email', value: 'alice@test.com' });
console.log('Email set:', state.email);

state = formReducer(state, { type: 'SET_ERROR', field: 'email', message: 'Invalid email' });
console.log('Error:', state.errors.email);

state = formReducer(state, { type: 'SUBMIT' });
console.log('Submitting:', state.isSubmitting);

Complex forms benefit from useReducer because multiple related values change together. Each action clearly describes what happened, and the reducer centralizes all state transitions.

Reducer with payload actions

function todoReducer(state, action) {
  switch (action.type) {
    case 'ADD':
      return [...state, { id: action.id, text: action.text, done: false }];
    case 'TOGGLE':
      return state.map(todo =>
        todo.id === action.id ? { ...todo, done: !todo.done } : todo
      );
    case 'REMOVE':
      return state.filter(todo => todo.id !== action.id);
    default:
      return state;
  }
}

let todos = [];

todos = todoReducer(todos, { type: 'ADD', id: 1, text: 'Learn React' });
todos = todoReducer(todos, { type: 'ADD', id: 2, text: 'Learn hooks' });
console.log('Todo count:', todos.length);

todos = todoReducer(todos, { type: 'TOGGLE', id: 1 });
console.log('Todo 1 done:', todos[0].done);
console.log('Todo 2 done:', todos[1].done);

todos = todoReducer(todos, { type: 'REMOVE', id: 2 });
console.log('After remove, count:', todos.length);
console.log('Remaining:', todos[0].text);

The todo reducer handles add, toggle, and remove operations. Each action carries the necessary payload (id, text) to perform the state transition. The reducer never mutates the existing state - it creates new arrays and objects.

Key points

Concepts covered

useReducer, reducer pattern, dispatch, actions, complex state logic