Docs LogoDocs

useReducer - Complex State Management

Documentation for useReducer - Complex State Management.

useReducer - Complex State Management

What is useReducer?

useReducer manages complex state logic with actions and a reducer function.

Definition: useReducer is an alternative to useState for managing state that involves complex logic, multiple sub-values, or when the next state depends on the previous one. It uses the Redux pattern: dispatch an action → reducer processes it → returns new state. This centralizes state update logic in one place.

Why Use useReducer?

Use CaseBenefit
Complex State LogicCentralized update logic
Multiple Sub-valuesHandle related state together
Dependent UpdatesNext state depends on previous
Shared LogicReuse reducer across components
Predictable UpdatesAction-based, testable updates

useState vs useReducer

┌─────────────────────────────────────────────────────────┐
│                 useState vs useReducer                  │
├─────────────────────────────────────────────────────────┤
│                                                         │
│   useState:                                             │
│   - Simple state updates                                │
│   - Direct value replacement                            │
│   - Logic scattered in handlers                         │
│   - setCount(count + 1)                                 │
│                                                         │
│   useReducer:                                           │
│   - Complex state logic                                 │
│   - Action-based updates                                │
│   - Centralized logic in reducer                        │
│   - dispatch({ type: 'INCREMENT' })                     │
│                                                         │
└─────────────────────────────────────────────────────────┘
FeatureuseStateuseReducer
ComplexitySimpleComplex
State shapeAnyOften objects
Update patternDirect replacementAction → reducer
Logic locationEvent handlersReducer function
TestingHarderEasier (pure function)
Best for1-2 values, simple logicMultiple values, complex

Basic Syntax

import { useReducer } from "react";

// Reducer function (pure function)
function reducer(state, action) {
  switch (action.type) {
    case "ACTION_TYPE":
      return { ...state /* updated values */ };
    default:
      return state;
  }
}

// Initial state
const initialState = { count: 0 };

function Component() {
  const [state, dispatch] = useReducer(reducer, initialState);

  // Dispatch an action
  const handleClick = () => {
    dispatch({ type: "ACTION_TYPE", payload: "data" });
  };

  return <div>{state.count}</div>;
}

Counter Example

With useState

function Counter() {
  const [count, setCount] = useState(0);

  return (
    <div>
      <p>Count: {count}</p>
      <button onClick={() => setCount(count + 1)}>+</button>
      <button onClick={() => setCount(count - 1)}>-</button>
      <button onClick={() => setCount(0)}>Reset</button>
    </div>
  );
}

With useReducer

function reducer(state, action) {
  switch (action.type) {
    case "INCREMENT":
      return { count: state.count + 1 };
    case "DECREMENT":
      return { count: state.count - 1 };
    case "RESET":
      return { count: 0 };
    case "SET":
      return { count: action.payload };
    default:
      throw new Error(`Unknown action: ${action.type}`);
  }
}

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

  return (
    <div>
      <p>Count: {state.count}</p>
      <button onClick={() => dispatch({ type: "INCREMENT" })}>+</button>
      <button onClick={() => dispatch({ type: "DECREMENT" })}>-</button>
      <button onClick={() => dispatch({ type: "RESET" })}>Reset</button>
      <button onClick={() => dispatch({ type: "SET", payload: 10 })}>
        Set to 10
      </button>
    </div>
  );
}

Todo List Example

const initialState = {
  todos: [],
  filter: "all", // all, active, completed
};

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 "SET_FILTER":
      return {
        ...state,
        filter: action.payload,
      };

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

    default:
      throw new Error(`Unknown action: ${action.type}`);
  }
}

function TodoApp() {
  const [state, dispatch] = useReducer(todoReducer, initialState);
  const [text, setText] = useState("");

  const filteredTodos = state.todos.filter((todo) => {
    if (state.filter === "active") return !todo.completed;
    if (state.filter === "completed") return todo.completed;
    return true;
  });

  const handleSubmit = (e) => {
    e.preventDefault();
    if (text.trim()) {
      dispatch({ type: "ADD_TODO", payload: text });
      setText("");
    }
  };

  return (
    <div>
      <form onSubmit={handleSubmit}>
        <input value={text} onChange={(e) => setText(e.target.value)} />
        <button type="submit">Add</button>
      </form>

      <div>
        <button
          onClick={() => dispatch({ type: "SET_FILTER", payload: "all" })}
        >
          All
        </button>
        <button
          onClick={() => dispatch({ type: "SET_FILTER", payload: "active" })}
        >
          Active
        </button>
        <button
          onClick={() => dispatch({ type: "SET_FILTER", payload: "completed" })}
        >
          Completed
        </button>
      </div>

      <ul>
        {filteredTodos.map((todo) => (
          <li key={todo.id}>
            <span
              onClick={() =>
                dispatch({ type: "TOGGLE_TODO", payload: todo.id })
              }
              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>
  );
}

Form Example

const initialFormState = {
  values: { name: "", email: "", password: "" },
  errors: {},
  isSubmitting: false,
  isValid: false,
};

function formReducer(state, action) {
  switch (action.type) {
    case "FIELD_CHANGE":
      const newValues = {
        ...state.values,
        [action.field]: action.value,
      };
      return {
        ...state,
        values: newValues,
        errors: { ...state.errors, [action.field]: null },
      };

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

    case "SET_ERRORS":
      return { ...state, errors: action.errors };

    case "SUBMIT_START":
      return { ...state, isSubmitting: true };

    case "SUBMIT_SUCCESS":
      return { ...initialFormState };

    case "SUBMIT_ERROR":
      return { ...state, isSubmitting: false, errors: action.errors };

    case "RESET":
      return initialFormState;

    default:
      return state;
  }
}

function SignupForm() {
  const [state, dispatch] = useReducer(formReducer, initialFormState);

  const handleChange = (e) => {
    dispatch({
      type: "FIELD_CHANGE",
      field: e.target.name,
      value: e.target.value,
    });
  };

  const handleSubmit = async (e) => {
    e.preventDefault();
    dispatch({ type: "SUBMIT_START" });

    try {
      await submitForm(state.values);
      dispatch({ type: "SUBMIT_SUCCESS" });
    } catch (errors) {
      dispatch({ type: "SUBMIT_ERROR", errors });
    }
  };

  return (
    <form onSubmit={handleSubmit}>
      <input name="name" value={state.values.name} onChange={handleChange} />
      {state.errors.name && <span>{state.errors.name}</span>}

      <input name="email" value={state.values.email} onChange={handleChange} />
      {state.errors.email && <span>{state.errors.email}</span>}

      <button disabled={state.isSubmitting}>
        {state.isSubmitting ? "Submitting..." : "Submit"}
      </button>
    </form>
  );
}

Lazy Initialization

For expensive initial state computation.

function init(initialCount) {
  // Expensive computation
  return { count: initialCount };
}

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

  return (
    <div>
      <p>Count: {state.count}</p>
      <button
        onClick={() => dispatch({ type: "RESET", payload: initialCount })}
      >
        Reset
      </button>
    </div>
  );
}

function reducer(state, action) {
  switch (action.type) {
    case "RESET":
      return init(action.payload);
    // ... other cases
  }
}

Action Creators

Helper functions that create action objects.

// Action creators
const actions = {
  increment: () => ({ type: "INCREMENT" }),
  decrement: () => ({ type: "DECREMENT" }),
  set: (value) => ({ type: "SET", payload: value }),
  reset: () => ({ type: "RESET" }),
};

// Usage
dispatch(actions.increment());
dispatch(actions.set(10));

Typing Actions (Best Practice)

// Define action types as constants
const ActionTypes = {
  ADD_TODO: "ADD_TODO",
  TOGGLE_TODO: "TOGGLE_TODO",
  DELETE_TODO: "DELETE_TODO",
};

// Use in reducer
function reducer(state, action) {
  switch (action.type) {
    case ActionTypes.ADD_TODO:
      return {
        /* ... */
      };
    // TypeScript would provide autocomplete here
  }
}

useReducer with useContext

Combine for global state management.

// StateContext.jsx
const StateContext = createContext();
const DispatchContext = createContext();

function StateProvider({ children }) {
  const [state, dispatch] = useReducer(reducer, initialState);

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

// Custom hooks
const useState = () => useContext(StateContext);
const useDispatch = () => useContext(DispatchContext);

// Usage in component
function TodoItem({ id }) {
  const state = useState();
  const dispatch = useDispatch();

  return (
    <div onClick={() => dispatch({ type: "TOGGLE", payload: id })}>
      {/* ... */}
    </div>
  );
}

Common Mistakes & Exceptions

1. Mutating State in Reducer

// ❌ Mutation
function reducer(state, action) {
  state.count++; // Mutating!
  return state; // Same reference = no re-render
}

// ✅ Return new object
function reducer(state, action) {
  return { ...state, count: state.count + 1 };
}

2. Missing Default Case

// ❌ Unknown action returns undefined
function reducer(state, action) {
  switch (action.type) {
    case "INCREMENT":
      return { ...state, count: state.count + 1 };
    // No default!
  }
}

// ✅ Handle unknown actions
function reducer(state, action) {
  switch (action.type) {
    case "INCREMENT":
      return { ...state, count: state.count + 1 };
    default:
      throw new Error(`Unknown action: ${action.type}`);
    // Or: return state;
  }
}

3. Complex Logic in Component

// ❌ Logic outside reducer
function Component() {
  const [state, dispatch] = useReducer(reducer, { items: [] });

  const addItem = (item) => {
    const exists = state.items.find((i) => i.id === item.id);
    if (exists) {
      // Logic here is hard to test
      dispatch({ type: "UPDATE", id: item.id });
    } else {
      dispatch({ type: "ADD", item });
    }
  };
}

// ✅ Logic in reducer
function reducer(state, action) {
  if (action.type === "ADD_OR_UPDATE") {
    const exists = state.items.find((i) => i.id === action.item.id);
    if (exists) {
      return { items: state.items.map(/*...*/) };
    }
    return { items: [...state.items, action.item] };
  }
}

When to Use useReducer

Use useReducerUse useState
Complex state objectsPrimitive values
State transitions depend on actionsSimple set operations
Related values update togetherIndependent values
Logic benefits from centralizationSimple update logic
Testing state logic is importantTesting isn't critical
Managing form stateToggle or counter

Interview Questions & Answers

Q1: What is useReducer and how does it differ from useState?

useReducer manages state with a reducer function that takes current state and an action, returning new state. It's based on the Redux pattern. Differences from useState: useState directly replaces state, useReducer uses actions; useState logic is in handlers, useReducer centralizes logic in the reducer; useReducer is better for complex state with multiple sub-values or when updates depend on previous state. Think of useState as a simplified useReducer for simple cases.


Q2: What is a reducer function and what rules should it follow?

A reducer is a pure function: (state, action) => newState. Rules: (1) Pure - no side effects, no API calls, no mutations. (2) Deterministic - same inputs always produce same output. (3) Return new state - never mutate the existing state. (4) Handle all actions - include a default case. (5) Synchronous - async operations happen before dispatching. These rules make reducers predictable, testable, and debuggable.


Q3: What is an action in useReducer?

An action is an object describing what happened. It must have a type property (string identifying the action) and optionally a payload with additional data. Example: { type: 'ADD_TODO', payload: { text: 'Learn React' } }. Convention: SCREAMING_SNAKE_CASE for types. Actions are dispatched to the reducer, which uses the type to determine how to update state. This separation makes state changes explicit and traceable.


Q4: When should you use useReducer instead of useState?

Use useReducer when: (1) State has multiple sub-values that often update together. (2) Next state depends on previous state. (3) State transitions are complex with many cases. (4) You want to centralize and test state logic. (5) Components need dispatch instead of multiple handlers. Stick with useState for simple values, toggles, or when the overhead of actions isn't worth it. The choice depends on complexity, not quantity.


Q5: How do you handle side effects with useReducer?

Reducers must be pure - no side effects inside. Handle side effects by: (1) Perform side effect first, then dispatch result. (2) Use useEffect to react to state changes. (3) Pattern: dispatch action → effect runs → dispatch completion action. For fetch: dispatch 'FETCH_START' → useEffect does fetch → dispatch 'FETCH_SUCCESS' or 'FETCH_ERROR' with data. The reducer only manages state; effects happen outside.


Q6: What is lazy initialization in useReducer?

Pass a third argument (init function) to useReducer: useReducer(reducer, initialArg, init). The init function receives initialArg and returns initial state. Benefits: expensive computation only runs once (not on re-renders), can reset state by dispatching with initial payload (dispatch({ type: 'RESET', payload: initialArg })), cleaner separation of initial state logic. Use for computations based on props or complex initial states.


Q7: How do you combine useReducer with useContext?

Create two contexts: one for state, one for dispatch. Provide both from a common provider component. Consumers use separate hooks to access what they need. This mimics Redux: centralized state + dispatch available anywhere. Benefit: components that only dispatch don't re-render on state changes. Pattern: const StateContext = createContext(); const DispatchContext = createContext(); then provide both values separately.


Q8: What are action creators and why use them?

Action creators are functions that create action objects: const addTodo = (text) => ({ type: 'ADD_TODO', payload: { text } }). Benefits: encapsulate action structure, provide autocomplete, easy to refactor types, can add validation logic, consistent action shape across app, enable reuse. Use with: dispatch(addTodo('Learn React')). In TypeScript, they provide type safety for actions.


Q9: How do you test a reducer function?

Reducers are pure functions, making them easy to test: call with a state and action, assert the returned state. Example: expect(reducer({ count: 0 }, { type: 'INCREMENT' })).toEqual({ count: 1 }). Test each action type, edge cases (undefined payload), and the default case. No mocking needed since there are no side effects. This testability is a major advantage of the reducer pattern.


Q10: What common mistakes occur when using useReducer?

Common mistakes: (1) Mutating state - use spread operator for new objects. (2) Missing default case - unhandled actions return undefined. (3) Side effects in reducer - keep pure, use effects outside. (4) Complex action logic in component - move to reducer. (5) Not using lazy init for expensive computations. (6) Overly granular actions - combine related updates. (7) String literals for types - use constants for typo prevention.

Last updated on July 15, 2026

On this page