REACT

React useReducer Hook: Syntax, Usage, and Examples

The React useReducer hook helps you manage complex state logic in function components. It’s especially useful when state changes depend on previous values or involve multiple sub-values.

This hook works similarly to a Redux reducer: you dispatch actions, and the reducer function determines how state updates. It’s a pattern many developers reach for in modern frontend applications that need predictable data flow.

You might choose useReducer React patterns over useState when dealing with more structured or scalable application state. Some developers also pair it with other tools like React router when coordinating state across content-heavy views.


How React useReducer Works

The useReducer hook accepts two arguments: a reducer function and an initial state. It returns the current state value and a dispatch function to trigger changes.

Syntax

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

The reducer is a pure function that receives the current state and an action, then returns a new state.

Many reducers use a switch statement because it keeps branching logic readable.

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

To update the state, you call the dispatch function with an action object. The reducer's update function decides how the state should change.

dispatch({ type: 'increment' });

React passes the updated state back into the component, which triggers re-renders.


Example: useReducer for a Counter

Here's a simple counter example using the React usereducer hook:

import { useReducer } from 'react';

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 };
    default:
      throw new Error();
  }
}

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

  return (
    <><p>Count: {state.count}</p>
      <button onClick={() => dispatch({ type: 'decrement' })}>-</button>
      <button onClick={() => dispatch({ type: 'increment' })}>+</button>
    </>
  );
}

Here, 0 acts as the initial value for the count.

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

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

  return (
    <><p>Count: {state.count}</p>
      <button onClick={() => dispatch({ type: 'decrement' })}>-</button>
      <button onClick={() => dispatch({ type: 'increment' })}>+</button>
    </>
  );
}

This approach separates the state logic from the UI, making the component easier to test and maintain. Components using this pattern are still regular React components, just with centralized logic.

In this example, each action updates the count and produces the next state, which React then uses to trigger a fresh render.


You Can Manage More Complex State

useReducer shines when your state has multiple values or you need to update it based on actions. It also helps avoid accidental mutations, which can cause confusing bugs.

const initialState = {
  user: null,
  loading: false,
  error: null
};

function reducer(state, action) {
  switch (action.type) {
    case 'FETCH_START':
      return { ...state, loading: true };
    case 'FETCH_SUCCESS':
      return { ...state, loading: false, user: action.payload };
    case 'FETCH_ERROR':
      return { ...state, loading: false, error: action.payload };
    default:
      return state;
  }
}

When reducers grow in size, many teams keep them in shared repos like Github to reuse across projects.

function reducer(state, action) {
  switch (action.type) {
    case 'FETCH_START':
      return { ...state, loading: true };
    case 'FETCH_SUCCESS':
      return { ...state, loading: false, user: action.payload };
    case 'FETCH_ERROR':
      return { ...state, loading: false, error: action.payload };
    default:
      return state;
  }
}

In this case, a single reducer handles all logic related to fetching a user.


Fetching API Data with useReducer

Many apps fetch data from an API, and useReducer works well for managing loading, success, and error states.

function reducer(state, action) {
  switch (action.type) {
    case "start":
      return { ...state, loading: true };
    case "success":
      return { ...state, loading: false, data: action.payload };
    case "failure":
      return { ...state, loading: false, error: action.payload };
    default:
      return state;
  }
}

This pattern keeps async updates consistent and easier to maintain.


You Should Use useReducer When State Logic Grows

React useState works well for simple state use cases. But when you:

  • Depend on previous state for updates
  • Have multiple values in a single state object
  • Want better organization
  • Need a Redux-like pattern without Redux

…you should consider using useReducer.

This is often the case in form management, nested updates, undo/redo functionality, and state machines.


useReducer with Initial State from Props

Sometimes you want to derive initial state from props. React lets you use an initializer function as a second argument to useReducer.

const init = (initialCount) => ({ count: initialCount });

const [state, dispatch] = useReducer(reducer, 5, init);

This pattern lets you defer expensive setup logic until the component actually mounts.


You Can Combine useReducer with Context

For global state sharing, combine useReducer React logic with React Context. This mimics Redux without extra libraries.

const StateContext = createContext();

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

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

function ChildComponent() {
  const { state, dispatch } = useContext(StateContext);
  return <div>{state.value}</div>;
}

Large applications often combine this with styles managed through CSS, which keeps UI and logic organized.

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

This pattern improves scalability and readability in large applications.


Using useReducer with useEffect

When state transitions depend on side effects (like fetching data, subscribing to updates, or syncing to localStorage), developers often pair useReducer with useEffect.

useEffect(() => {
  dispatch({ type: "start" });
  fetch("/api/data")
    .then(r => r.json())
    .then(data => dispatch({ type: "success", payload: data }));
}, []);

The array at the end acts as a dependency list.


Stabilizing Dispatch with useCallback

If child components rely on functions that trigger reducer actions, you can wrap handlers in useCallback to avoid unnecessary re-renders.

const handleAdd = useCallback(() => {
  dispatch({ type: "add" });
}, [dispatch]);

This keeps event handlers stable across renders.


Memoizing Derived State with useMemo

Sometimes you need expensive calculations based on reducer state. You can speed this up with useMemo.

const total = useMemo(() => {
  return state.items.reduce((sum, item) => sum + item.price, 0);
}, [state.items]);

This avoids recalculating values when they haven't changed.

How to Use useReducer in React for Controlled Forms

Managing form state with useReducer lets you centralize logic and validations.

const formReducer = (state, action) => {
  return { ...state, [action.name]: action.value };
};

const [formState, dispatch] = useReducer(formReducer, {
  username: "",
  email: ""
});

<inputname="username"
  value={formState.username}
  onChange={(e) =>
    dispatch({ name: e.target.name, value: e.target.value })
  }
/>

This approach avoids managing multiple useState hooks for each field.


Using useReducer in TypeScript

Developers working in TypeScript often use typed actions to make reducers more predictable.

type Action =
  | { type: "increment" }
  | { type: "decrement" };

function reducer(state: { count: number }, action: Action) {
  switch (action.type) {
    case "increment":
      return { count: state.count + 1 };
    default:
      return state;
  }
}

Types help prevent dispatching unsupported actions.


Debugging and Testing Reducers

Because reducer functions are pure, you can write tests for them without rendering components.

test('should increment count', () => {
  const initialState = { count: 0 };
  const action = { type: 'increment' };
  const result = reducer(initialState, action);
  expect(result.count).toBe(1);
});

This makes useReducer a great fit for apps that need strong test coverage.


Best Practices for useReducer in React

  • Keep the reducer pure: no side effects or async code inside it.
  • Use constants for action types to avoid typos.
  • Create action creators for readability.
  • Separate logic-heavy reducers into their own files.
  • Avoid unnecessary nesting of state.

Following these practices ensures that your useReducer logic is maintainable and scalable.


useReducer vs useState

Choosing between useReducer and useState depends on the complexity of the state you're managing.

  • Use useState when you're working with simple, independent values like toggles, counters, or form fields with straightforward logic. It's lightweight and easy to understand for these cases.
  • Switch to useReducer when state updates depend on previous values, when you have multiple related pieces of state, or when the logic for updating state gets repetitive or hard to manage with multiple useState hooks.
  • If your component needs to handle more structured logic, like conditionally updating nested properties or running multiple state updates at once, useReducer gives you a clearer and more maintainable way to do it.
  • In large applications or shared state scenarios, combining useReducer with Context can provide a Redux-like structure without the extra libraries.

By thinking about the shape and complexity of your state, you can choose the tool that fits best—useState for simplicity, or useReducer for structure and control.


Example: useReducer in a Todo App

const initialState = [];

function todoReducer(state, action) {
  switch (action.type) {
    case "ADD_TODO":
      return [...state, { id: Date.now(), text: action.payload }];
    case "REMOVE_TODO":
      return state.filter(todo => todo.id !== action.payload);
    default:
      return state;
  }
}

Use dispatch to add or remove todos:

dispatch({ type: "ADD_TODO", payload: "Buy milk" });
dispatch({ type: "REMOVE_TODO", payload: 12345 });

This structure handles dynamic lists in a clean, scalable way.


The useReducer React hook offers a structured way to manage state updates, especially when those updates rely on previous values or involve multiple conditions. You define a reducer function, pass an initial state, and use dispatch to trigger changes.

By understanding how to use useReducer in React effectively, you can:

  • Simplify complex logic
  • Keep state transitions predictable
  • Centralize updates into one function
  • Improve testability and organization

This hook is a solid choice for advanced state management in React apps without needing a full external state library.

Learn to Code in React for Free
Start learning now
button icon
To advance beyond this tutorial and learn React by doing, try the interactive experience of Mimo. Whether you're starting from scratch or brushing up your coding skills, Mimo helps you take your coding journey above and beyond.

Sign up or download Mimo from the App Store or Google Play to enhance your programming skills and prepare for a career in tech.