React.js Fundamentals

useReducer Hook - Complex State Management

18 min Lesson 14 of 40

Understanding useReducer

The useReducer hook is an alternative to useState for managing complex state logic. It's similar to how Redux works - you dispatch actions to a reducer function, which calculates the next state based on the current state and action.

useReducer is particularly useful when you have complex state logic involving multiple sub-values, when the next state depends on the previous one, or when you want to optimize performance for components that trigger deep updates.

When to Use useReducer

Use useReducer when: state updates involve complex logic, multiple related state values change together, next state depends on previous state, you need predictable state transitions, or you want to separate state logic from component code.

Basic useReducer Syntax

The useReducer hook takes three arguments: a reducer function, an initial state, and an optional init function. It returns the current state and a dispatch function.

import React, { useReducer } from 'react';

// Reducer function: (currentState, action) => newState
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:
      throw new Error(`Unknown action type: ${action.type}`);
  }
}

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

  return (
    <div>
      <h2>Count: {state.count}</h2>
      <button onClick={() => dispatch({ type: 'INCREMENT' })}>
        +1
      </button>
      <button onClick={() => dispatch({ type: 'DECREMENT' })}>
        -1
      </button>
      <button onClick={() => dispatch({ type: 'RESET' })}>
        Reset
      </button>
    </div>
  );
}

export default Counter;

Actions with Payloads

Actions can carry additional data (payload) to the reducer:

function counterReducer(state, action) {
  switch (action.type) {
    case 'INCREMENT':
      return { count: state.count + 1 };
    case 'DECREMENT':
      return { count: state.count - 1 };
    case 'ADD':
      return { count: state.count + action.payload };
    case 'SUBTRACT':
      return { count: state.count - action.payload };
    case 'SET':
      return { count: action.payload };
    default:
      return state;
  }
}

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

  return (
    <div>
      <h2>Count: {state.count}</h2>
      <button onClick={() => dispatch({ type: 'INCREMENT' })}>
        +1
      </button>
      <button onClick={() => dispatch({ type: 'ADD', payload: 5 })}>
        +5
      </button>
      <button onClick={() => dispatch({ type: 'ADD', payload: 10 })}>
        +10
      </button>
      <button onClick={() => dispatch({ type: 'SET', payload: 0 })}>
        Reset
      </button>
    </div>
  );
}

Reducer Best Practices

Always return a new state object (don't mutate), use descriptive action types (constants), handle all cases or return current state in default, and keep reducers pure (no side effects).

Complex State Example: Todo List

Managing a todo list with multiple operations:

import React, { useReducer, useState } from 'react';

function todoReducer(state, action) {
  switch (action.type) {
    case 'ADD_TODO':
      return {
        ...state,
        todos: [
          ...state.todos,
          {
            id: Date.now(),
            text: action.payload,
            completed: false
          }
        ]
      };

    case 'TOGGLE_TODO':
      return {
        ...state,
        todos: state.todos.map(todo =>
          todo.id === action.payload
            ? { ...todo, completed: !todo.completed }
            : todo
        )
      };

    case 'DELETE_TODO':
      return {
        ...state,
        todos: state.todos.filter(todo => todo.id !== action.payload)
      };

    case 'EDIT_TODO':
      return {
        ...state,
        todos: state.todos.map(todo =>
          todo.id === action.payload.id
            ? { ...todo, text: action.payload.text }
            : todo
        )
      };

    case 'CLEAR_COMPLETED':
      return {
        ...state,
        todos: state.todos.filter(todo => !todo.completed)
      };

    default:
      return state;
  }
}

function TodoApp() {
  const [state, dispatch] = useReducer(todoReducer, { todos: [] });
  const [inputValue, setInputValue] = useState('');

  const addTodo = (e) => {
    e.preventDefault();
    if (inputValue.trim()) {
      dispatch({ type: 'ADD_TODO', payload: inputValue });
      setInputValue('');
    }
  };

  return (
    <div>
      <h2>Todo List</h2>
      <form onSubmit={addTodo}>
        <input
          type="text"
          value={inputValue}
          onChange={(e) => setInputValue(e.target.value)}
          placeholder="Add a todo..."
        />
        <button type="submit">Add</button>
      </form>

      <ul>
        {state.todos.map(todo => (
          <li key={todo.id}>
            <input
              type="checkbox"
              checked={todo.completed}
              onChange={() => dispatch({ type: 'TOGGLE_TODO', payload: todo.id })}
            />
            <span style={{ textDecoration: todo.completed ? 'line-through' : 'none' }}>
              {todo.text}
            </span>
            <button onClick={() => dispatch({ type: 'DELETE_TODO', payload: todo.id })}>
              Delete
            </button>
          </li>
        ))}
      </ul>

      <button onClick={() => dispatch({ type: 'CLEAR_COMPLETED' })}>
        Clear Completed
      </button>
    </div>
  );
}

export default TodoApp;

useReducer vs useState

Choosing between useReducer and useState:

// useState - Simple state
function Counter() {
  const [count, setCount] = useState(0);
  return <button onClick={() => setCount(count + 1)}>{count}</button>;
}

// useReducer - Complex state with multiple operations
function Counter() {
  const [state, dispatch] = useReducer(reducer, initialState);
  return (
    <div>
      <button onClick={() => dispatch({ type: 'INCREMENT' })}>+</button>
      <button onClick={() => dispatch({ type: 'DECREMENT' })}>-</button>
      <button onClick={() => dispatch({ type: 'RESET' })}>Reset</button>
    </div>
  );
}

Common Mistake: Mutating State

Never mutate state directly in a reducer. Always return a new state object. Use spread operators or methods like map() and filter() that return new arrays.

Lazy Initialization

Use an init function for expensive initial state calculations:

function init(initialCount) {
  // Expensive calculation
  return { count: initialCount * 2 };
}

function Counter({ initialCount }) {
  const [state, dispatch] = useReducer(reducer, initialCount, init);

  // The init function is only called once on mount
  return <div>Count: {state.count}</div>;
}

Form Management with useReducer

Managing complex form state:

import React, { useReducer } from 'react';

function formReducer(state, action) {
  switch (action.type) {
    case 'SET_FIELD':
      return {
        ...state,
        values: {
          ...state.values,
          [action.field]: action.value
        }
      };

    case 'SET_ERROR':
      return {
        ...state,
        errors: {
          ...state.errors,
          [action.field]: action.error
        }
      };

    case 'SET_SUBMITTING':
      return {
        ...state,
        isSubmitting: action.value
      };

    case 'RESET_FORM':
      return action.payload;

    default:
      return state;
  }
}

function RegistrationForm() {
  const initialState = {
    values: {
      username: '',
      email: '',
      password: ''
    },
    errors: {},
    isSubmitting: false
  };

  const [state, dispatch] = useReducer(formReducer, initialState);

  const handleChange = (field) => (e) => {
    dispatch({
      type: 'SET_FIELD',
      field,
      value: e.target.value
    });
  };

  const validate = () => {
    const newErrors = {};

    if (!state.values.username) {
      newErrors.username = 'Username is required';
    }

    if (!state.values.email.includes('@')) {
      newErrors.email = 'Invalid email';
    }

    if (state.values.password.length < 6) {
      newErrors.password = 'Password must be at least 6 characters';
    }

    return newErrors;
  };

  const handleSubmit = async (e) => {
    e.preventDefault();

    const errors = validate();

    if (Object.keys(errors).length > 0) {
      Object.entries(errors).forEach(([field, error]) => {
        dispatch({ type: 'SET_ERROR', field, error });
      });
      return;
    }

    dispatch({ type: 'SET_SUBMITTING', value: true });

    try {
      // Simulate API call
      await new Promise(resolve => setTimeout(resolve, 1000));
      console.log('Form submitted:', state.values);
      dispatch({ type: 'RESET_FORM', payload: initialState });
    } catch (error) {
      console.error('Submit error:', error);
    } finally {
      dispatch({ type: 'SET_SUBMITTING', value: false });
    }
  };

  return (
    <form onSubmit={handleSubmit}>
      <div>
        <label>Username:</label>
        <input
          type="text"
          value={state.values.username}
          onChange={handleChange('username')}
        />
        {state.errors.username && <span>{state.errors.username}</span>}
      </div>

      <div>
        <label>Email:</label>
        <input
          type="email"
          value={state.values.email}
          onChange={handleChange('email')}
        />
        {state.errors.email && <span>{state.errors.email}</span>}
      </div>

      <div>
        <label>Password:</label>
        <input
          type="password"
          value={state.values.password}
          onChange={handleChange('password')}
        />
        {state.errors.password && <span>{state.errors.password}</span>}
      </div>

      <button type="submit" disabled={state.isSubmitting}>
        {state.isSubmitting ? 'Submitting...' : 'Register'}
      </button>
    </form>
  );
}

export default RegistrationForm;

Combining useReducer with Context

Create a global state management solution by combining useReducer with Context:

import React, { createContext, useContext, useReducer } from 'react';

// Actions
const INCREMENT = 'INCREMENT';
const DECREMENT = 'DECREMENT';
const RESET = 'RESET';

// Reducer
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;
  }
}

// Context
const CounterContext = createContext();

// Provider
export function CounterProvider({ children }) {
  const [state, dispatch] = useReducer(counterReducer, { count: 0 });

  return (
    <CounterContext.Provider value={{ state, dispatch }}>
      {children}
    </CounterContext.Provider>
  );
}

// Custom hook
export function useCounter() {
  const context = useContext(CounterContext);
  if (!context) {
    throw new Error('useCounter must be used within CounterProvider');
  }
  return context;
}

// Usage in components
function CounterDisplay() {
  const { state } = useCounter();
  return <h2>Count: {state.count}</h2>;
}

function CounterButtons() {
  const { dispatch } = useCounter();
  return (
    <div>
      <button onClick={() => dispatch({ type: INCREMENT })}>+</button>
      <button onClick={() => dispatch({ type: DECREMENT })}>-</button>
      <button onClick={() => dispatch({ type: RESET })}>Reset</button>
    </div>
  );
}

function App() {
  return (
    <CounterProvider>
      <CounterDisplay />
      <CounterButtons />
    </CounterProvider>
  );
}

Multiple Reducers Pattern

Use multiple reducers for different parts of your application:

function App() {
  const [userState, userDispatch] = useReducer(userReducer, initialUserState);
  const [cartState, cartDispatch] = useReducer(cartReducer, initialCartState);
  const [uiState, uiDispatch] = useReducer(uiReducer, initialUIState);

  return (
    <UserContext.Provider value={{ userState, userDispatch }}>
      <CartContext.Provider value={{ cartState, cartDispatch }}>
        <UIContext.Provider value={{ uiState, uiDispatch }}>
          <AppContent />
        </UIContext.Provider>
      </CartContext.Provider>
    </UserContext.Provider>
  );
}

Action Creators Pattern

Create helper functions to generate actions:

// Action creators
const actions = {
  addTodo: (text) => ({ type: 'ADD_TODO', payload: text }),
  toggleTodo: (id) => ({ type: 'TOGGLE_TODO', payload: id }),
  deleteTodo: (id) => ({ type: 'DELETE_TODO', payload: id }),
  editTodo: (id, text) => ({ type: 'EDIT_TODO', payload: { id, text } })
};

// Usage
function TodoList() {
  const [state, dispatch] = useReducer(todoReducer, initialState);

  return (
    <div>
      <button onClick={() => dispatch(actions.addTodo('New task'))}>
        Add Todo
      </button>
      {state.todos.map(todo => (
        <div key={todo.id}>
          <span onClick={() => dispatch(actions.toggleTodo(todo.id))}>
            {todo.text}
          </span>
          <button onClick={() => dispatch(actions.deleteTodo(todo.id))}>
            Delete
          </button>
        </div>
      ))}
    </div>
  );
}

Exercise 1: Shopping Cart

Build a shopping cart using useReducer that supports adding, removing, and updating quantities.

// Requirements:
// 1. Add item to cart (or increment if exists)
// 2. Remove item from cart
// 3. Update item quantity
// 4. Clear entire cart
// 5. Display total price and item count

Exercise 2: Multi-Step Form

Create a multi-step registration form with useReducer managing form state and navigation.

// Requirements:
// 1. Three steps: Personal Info, Account Details, Confirmation
// 2. Next/Previous/Submit buttons
// 3. Validate each step before proceeding
// 4. Show errors per field
// 5. Display summary on confirmation step

Exercise 3: Quiz Application

Build a quiz app with useReducer managing questions, answers, score, and navigation.

// Requirements:
// 1. Display one question at a time
// 2. Track selected answers
// 3. Calculate score
// 4. Show results at the end
// 5. Reset quiz functionality

Summary

  • useReducer manages complex state with predictable updates via actions
  • Reducer function: (state, action) => newState
  • Use for complex state logic, multiple related values, or when next state depends on previous
  • Actions can carry payloads for additional data
  • Always return new state objects (immutable updates)
  • Combine with Context for global state management
  • Use action creators for cleaner dispatch calls
  • Lazy initialization for expensive initial state calculations