React 19 useReducer Deep Dive — From Basics to Complex State Patterns



This content originally appeared on DEV Community and was authored by Ali Aslam

useState is the golden child of React hooks.
It’s simple, it’s intuitive, and for most components… it’s all you need.

But there comes a moment in every React developer’s journey when useState starts to feel… clumsy.

Maybe you’ve been there:

  • You’ve got three or four related pieces of state that all need to change together.
  • Updating them means juggling multiple setX calls — and hoping you don’t forget one.
  • Your component starts looking like a tangle of state setters and useEffect calls just to keep things in sync.

Suddenly, that neat little hook feels less like a clean tool and more like a handful of sticky notes scattered across your desk.
You can still work with them… but every update feels like a mini scavenger hunt.

That’s where useReducer comes in.

Table of Contents

🧠 Core Concepts

  • The Mental Model of useReducer
  • Basic useReducer in Action
  • Organizing Actions & State

🔧 Practical Patterns

  • Common useReducer Patterns
    • Dynamic Form Management
    • Undo / Redo State
    • Derived State Inside Reducers
    • Toggle Pattern
    • Reset State
  • Complex Scenarios
    • Lazy Initialization
    • Resetting State on Certain Actions
    • Combining Multiple Reducers
    • Avoiding Deeply Nested State

⚡ Beyond the Basics

  • Performance Tips
  • useReducer + Context for Global State
  • When to Use useReducer (and When Not To)
  • Wrap-Up

Instead of sprinkling state logic across multiple places, useReducer lets you put all your “state change rules” in one centralized function — like replacing those sticky notes with a big whiteboard that clearly says:

“When X happens, state should look like this.”

With useReducer, you send an action (“increment counter”, “update form field”, “reset to initial state”) to one place — the reducer — and it decides exactly how to transform the state.

In this article, we’ll explore:

  • How useReducer works under the hood
  • Why it’s perfect for complex or interrelated state
  • Common patterns you’ll actually use in real projects
  • Performance tips and React 19–specific nuances
  • How to combine it with Context for powerful, predictable global state

By the end, you’ll know exactly when to reach for useReducer and how to wield it without over-engineering your components.

The Mental Model of useReducer

Before we even look at code, let’s make sure we have a clear picture in our heads of what useReducer is and why it’s different from useState.

Think of your component’s state as a snapshot of your app’s memory at a given moment.
When you call useState, you say:

“Hey React, store this piece of data for me, and here’s a little function I’ll use to replace it when it changes.”

With useReducer, we’re doing something slightly different:

“Hey React, store this piece of data for me… but instead of giving me a setter, give me a way to send instructions for how to change it. You figure out the new state based on those instructions.”

Those “instructions” are called actions.
They’re just objects that describe what happened in your app — like { type: "increment" } or { type: "add_todo", text: "Buy milk" }.

How the pieces fit together

useReducer works in a loop:

  1. State lives in React — just like with useState.
  2. When something happens (user clicks, form submits, API finishes loading), you call dispatch(action).
  3. React passes the current state and the action to your reducer function.
  4. The reducer returns a brand-new state object (not a mutation of the old one!).
  5. React re-renders your component with this new state.

The “Inbox” analogy

If useState is like having a single remote control with a “set” button,
useReducer is like having a mailbox labeled “State Changes.”

Every time something needs to change, you drop a little “change request” (action) into the mailbox.
The reducer is the person checking the mail, reading each request, and updating the official state book accordingly.

The reducer function

A reducer is just a plain function with this signature:

function reducer(currentState, action) {
  // decide how to turn currentState + action into new state
  return newState;
}

Two big rules for reducers:

  1. They must be pure functions — given the same inputs, they must always return the same output. No randomness, no API calls, no modifying variables outside their scope.
  2. They must not mutate the state — instead, return a new object/array so React can detect the change.

👉 In React, mutate means directly changing an object or array in state. To trigger a re-render, the memory location must change, which is why you should always return a new object/array instead of editing the existing one. (or say variable should be replaced, not edited

Quick example

Here’s the world’s simplest useReducer counter:

import { useReducer } from "react";

function counterReducer(state, action) {
  if (action.type === "increment") {
    return { count: state.count + 1 };
  }
  if (action.type === "decrement") {
    return { count: state.count - 1 };
  }
  return state; // if unknown action, return unchanged state
}

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

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

Key takeaways from this example:

  • We describe what happened ({ type: "increment" }) instead of directly setting the value.
  • The reducer decides how to update the state based on the action.
  • All update logic is centralized in one place — no scattered setState calls.

Checkpoint 🛑
If you can picture a little “action” envelope traveling from the component to the reducer,
and the reducer returning a fresh state based on the request,
you’re ready to move on to writing more complex reducers.

Basic useReducer in Action

Now that we’ve got the mental model down, let’s write a slightly richer example together.
We’ll start with something you’ve probably built before — a counter — but we’ll add a reset action and make the reducer a bit more structured.

Step 1 — The initial state

Just like with useState, we decide what our state looks like at the start:

const initialState = { count: 0 };

Step 2 — The reducer function

Here’s the heart of useReducer — the function that takes the current state and an action and returns the next state.

We’ll use a switch statement here so it’s easy to see how different actions map to changes:

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; // If the action type is unknown, return the current state unchanged
  }
}

Step 3 — Hooking it up in a component

Now we connect the reducer to our component with the useReducer hook:

import { useReducer } from "react";

export default function Counter() {
  const [state, dispatch] = useReducer(counterReducer, initialState);

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

Step 4 — How it flows

Let’s trace what happens when you click the “+” button:

  1. The button calls dispatch({ type: "increment" }).
  2. React hands the current state { count: 0 } and the action { type: "increment" } to counterReducer.
  3. counterReducer sees "increment" and returns { count: 1 }.
  4. React updates the state and re-renders the component with count: 1.

Why this is better than useState here

  • All logic for updating the counter lives in one place — the reducer.
  • Adding a new action type is easy — no hunting down scattered setCount calls.
  • You can quickly see every possible way the state can change just by scanning the reducer.

Checkpoint 🛑
If you understand how dispatch triggers the reducer,
and how the reducer returns the next state without mutating the old one,
you’ve grasped the core loop of useReducer.

From here, we can start handling more complex state shapes — objects, arrays, and even entire forms.

Organizing Actions & State

So far, our state has been one number. Easy.
But in real-world apps, state is often an object with multiple properties — sometimes even deeply nested.
With useReducer, we can keep that complexity organized so it doesn’t turn into spaghetti.

From one value to an object

Let’s say we’re tracking a user’s profile info in a form:

const initialState = {
  name: "",
  email: "",
  age: ""
};

Action structure

For more complex state, actions often have two parts:

  1. type → a string that describes what happened.
  2. payload → the extra data we need to perform the update.

Example:

{ type: "update_field", payload: { field: "name", value: "Alice" } }

Reducer with multiple state keys

Here’s one way to handle form field updates:

function profileReducer(state, action) {
  switch (action.type) {
    case "update_field":
      return {
        ...state,
        [action.payload.field]: action.payload.value
      };
    case "reset":
      return initialState;
    default:
      return state;
  }
}

Notice:

  • We spread ...state to keep all other fields unchanged.
  • We use a computed property name [action.payload.field] so the reducer can update any field dynamically.

Component example

import { useReducer } from "react";

export default function ProfileForm() {
  const [state, dispatch] = useReducer(profileReducer, initialState);

  function handleChange(e) {
    dispatch({
      type: "update_field",
      payload: { field: e.target.name, value: e.target.value }
    });
  }

  return (
    <form>
      <input name="name" value={state.name} onChange={handleChange} />
      <input name="email" value={state.email} onChange={handleChange} />
      <input name="age" value={state.age} onChange={handleChange} />
      <button type="button" onClick={() => dispatch({ type: "reset" })}>
        Reset
      </button>
    </form>
  );
}

Why this is powerful

  • Scalable — adding new fields doesn’t require a new setter for each one.
  • Predictable — every possible change is described in a small set of action types.
  • Debuggable — you can log every action and see exactly how state changed.

Organizing action types

When a reducer grows, it’s easy to miss typos like "udpate_field".
A common pattern is to store action types as constants:

const UPDATE_FIELD = "update_field";
const RESET = "reset";

Then in your reducer:

case UPDATE_FIELD:
  ...

This small step can save hours of debugging.

Checkpoint 🛑
You now know how to handle multi-key state without it becoming a mess.
Up next, we’ll look at some common patterns for useReducer — including one that adds derived state directly into the reducer so it’s always consistent.

Common useReducer Patterns

useReducer really shines when you start spotting repeatable patterns for managing complex state.
Here are a few you’ll actually use in real projects.

1. Dynamic Form Management (Multiple Fields)

We already saw this in the previous section — one update_field action that updates any field in the form.
This pattern is especially powerful when you’re building forms where the number of fields can grow or change dynamically (think settings pages or generated forms).

Key advantages:

  • One handler for all fields.
  • Reducer remains predictable and centralizes all update logic.

2. Undo / Redo State

Because reducers are pure functions, they work beautifully with history tracking.

const initialState = {
  past: [],
  present: 0,
  future: []
};

function historyReducer(state, action) {
  switch (action.type) {
    case "increment":
      return {
        past: [...state.past, state.present],
        present: state.present + 1,
        future: []
      };
    case "undo":
      if (state.past.length === 0) return state;
      return {
        past: state.past.slice(0, -1),
        present: state.past[state.past.length - 1],
        future: [state.present, ...state.future]
      };
    case "redo":
      if (state.future.length === 0) return state;
      return {
        past: [...state.past, state.present],
        present: state.future[0],
        future: state.future.slice(1)
      };
    default:
      return state;
  }
}

Why it works:

  • Every change returns a new state object, so keeping past/future snapshots is easy.
  • No accidental overwriting of history.

3. Derived State Inside Reducers

Sometimes your state needs extra computed values that depend on other pieces of state.
Instead of recalculating them in every component render, you can compute them inside the reducer so they’re always in sync.

Example:

const initialState = { items: [], total: 0 };

function cartReducer(state, action) {
  switch (action.type) {
    case "add_item": {
      const newItems = [...state.items, action.payload];
      return {
        items: newItems,
        total: newItems.reduce((sum, item) => sum + item.price, 0)
      };
    }
    case "remove_item": {
      const newItems = state.items.filter((_, i) => i !== action.payload);
      return {
        items: newItems,
        total: newItems.reduce((sum, item) => sum + item.price, 0)
      };
    }
    default:
      return state;
  }
}

Why this is nice:

  • Components never have to remember to calculate totals — the reducer does it.
  • Total is always correct because it’s recalculated on every state change.

4. Toggle Pattern

For boolean flags (menus, modals, feature switches), a toggle action keeps the reducer clean:

function uiReducer(state, action) {
  switch (action.type) {
    case "toggle_modal":
      return { ...state, isModalOpen: !state.isModalOpen };
    default:
      return state;
  }
}

Why it’s nice:

  • Single action for both open and close.
  • No guessing whether you should dispatch "open" or "close".

5. Reset State

We saw this earlier, but it’s worth calling out — a reset action is your “panic button”:

case "reset":
  return initialState;

Simple, predictable, and very handy when you need to clear a form, restart a wizard, or restore defaults.

Checkpoint 🛑
If you can see how forms, undo/redo, derived state, toggles, and resets fit naturally into a reducer,
you’re ready for the next step: handling complex reducer setups — multiple reducers, nested state, and performance considerations.

Complex Scenarios

Once you’re comfortable with the basics, useReducer can handle very advanced state setups without losing its predictability.
Let’s go through some scenarios you’ll hit in real projects.

1. Lazy Initialization

Sometimes the initial state is expensive to compute — maybe it involves parsing data, reading from localStorage, or doing some math.

If you pass a function as the third argument to useReducer, React will call it only once (on the first render) to produce the initial state.

function init(initialCount) {
  console.log("Calculating initial state...");
  return { count: initialCount };
}

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

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

  return (
    <>
      <p>Count: {state.count}</p>
      <button onClick={() => dispatch({ type: "decrement" })}>-</button>
      <button onClick={() => dispatch({ type: "increment" })}>+</button>
      <button onClick={() => dispatch({ type: "reset", payload: initialCount })}>
        Reset
      </button>
    </>
  );
}

Why this is nice:

  • Expensive setup runs only once instead of on every render.
  • Makes resetting state easy by calling the init function again.

2. Resetting State on Certain Actions

Sometimes you need to reset state when a user logs out, a form is submitted, or a timer ends.
Instead of manually clearing each field, just return initialState in the reducer:

case "logout":
  return initialState;

It’s like hitting the reset button for your entire state tree.

3. Combining Multiple Reducers

When a reducer starts to get too big, split it into smaller reducers and combine them manually.

function profileReducer(state, action) { /* ... */ }
function settingsReducer(state, action) { /* ... */ }

function rootReducer(state, action) {
  return {
    profile: profileReducer(state.profile, action),
    settings: settingsReducer(state.settings, action)
  };
}

const initialState = {
  profile: { name: "", email: "" },
  settings: { theme: "light", notifications: true }
};

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

Why this works:

  • Each sub-reducer focuses on one part of state.
  • Easier to test and reason about.

4. Avoiding Deeply Nested State

While you can have state like { user: { address: { city: "Paris" } } },
updating deeply nested values immutably can get messy.

Two options:

  1. Flatten the shape if possible.
  2. Use libraries like Immer to write “mutable-looking” updates that are actually immutable.

Example with Immer:

import produce from "immer";

function reducer(state, action) {
  switch (action.type) {
    case "update_city":
      return produce(state, draft => {
        draft.user.address.city = action.payload;
      });
    default:
      return state;
  }
}

Checkpoint 🛑
At this stage, you’ve seen:

  • How to initialize complex state only once.
  • How to reset state in one shot.
  • How to combine reducers for cleaner code.
  • How to manage nested state without headaches.

Performance Tips

Most of the time, you can write reducers without thinking too hard about performance.
React is very efficient at re-rendering only what’s necessary.
But as your app grows, there are a few best practices that can help keep things snappy.

1. Don’t Overuse useReducer

useReducer isn’t always the most efficient choice.
For small, independent pieces of state (like a single input value), useState is often simpler and faster to write.
useReducer shines when state updates are related and can be centralized.

2. Keep State as Small as Possible

The bigger your state object, the more work React has to do when it changes.
If unrelated data is stored together, any change will cause re-renders for all consumers of that state.

Fix:

  • Split state into multiple reducers (local or via context).
  • Don’t store derived values that you can calculate on the fly unless they’re expensive to compute.

3. Memoize Expensive Selectors

If you’re calculating something heavy from state, use useMemo in your component so it only recomputes when needed.

const totalPrice = useMemo(
  () => state.items.reduce((sum, item) => sum + item.price, 0),
  [state.items]
);

4. Prevent Unnecessary Re-renders in Child Components

If you pass parts of state to child components:

  • Use React.memo for children that don’t need to re-render every time.
  • Pass only the data the child needs — not the whole state object.
const ItemList = React.memo(function ItemList({ items }) {
  return items.map(item => <li key={item.id}>{item.name}</li>);
});

5. Don’t Worry About dispatch Identity

One nice thing about useReducer — the dispatch function never changes.
This means you can safely pass it down to children without causing re-renders due to changing function references.
(This is different from useState setters, which also have stable identity but are often wrapped in callbacks for custom logic.)

6. React 19 Concurrency Notes

React 19’s concurrent rendering means reducers might be called multiple times before committing changes to the DOM.
But reducers are pure, so this is safe — they’ll always return the same output for the same input.

The main takeaway:

  • Don’t put side effects in reducers — not even logging analytics or API calls.
  • Keep them pure, and run side effects in useEffect or useLayoutEffect instead.

Checkpoint 🛑
If you follow these tips, your reducers will stay predictable, efficient, and easy to debug — even as your app scales.

useReducer + Context for Global State

So far, our reducers have managed local state — state that only lives inside a single component.
But what if multiple components across your app need to share the same state and update it?

You could pass state down through props (lifting state up),
but if you’ve tried that before, you know it quickly turns into prop drilling hell.

This is where useReducer + React’s Context API make a great team.

Why useReducer + Context works so well

  • useReducer centralizes how state changes.
  • Context allows any component to read that state and dispatch actions, without passing props down through every level.

It’s basically building your own mini Redux — but with React’s built-in tools.

Step 1 — Create the Context

import { createContext, useReducer, useContext } from "react";

const CounterContext = createContext();

Step 2 — Create a Provider Component

This wraps your app (or part of it) and makes the state + dispatch available.

const initialState = { count: 0 };

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

export function CounterProvider({ children }) {
  const [state, dispatch] = useReducer(counterReducer, initialState);

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

Step 3 — Create a Custom Hook for Easy Access

This avoids importing useContext and CounterContext everywhere.

export function useCounter() {
  return useContext(CounterContext);
}

Step 4 — Use It in Components Anywhere

function CounterDisplay() {
  const { state } = useCounter();
  return <p>Count: {state.count}</p>;
}

function CounterControls() {
  const { dispatch } = useCounter();
  return (
    <>
      <button onClick={() => dispatch({ type: "decrement" })}>-</button>
      <button onClick={() => dispatch({ type: "increment" })}>+</button>
    </>
  );
}

export default function App() {
  return (
    <CounterProvider>
      <CounterDisplay />
      <CounterControls />
    </CounterProvider>
  );
}

Benefits

  • No prop drilling — any component inside CounterProvider can get state and dispatch.
  • All state updates still go through the reducer — making behavior predictable.
  • Easy to test — you can test the reducer independently from the UI.

Checkpoint 🛑
If you understand how we combined useReducer for logic and Context for distribution,
you’ve essentially built your own app-wide state store — no Redux required.

When to Use useReducer (and When Not To)

By now, you’ve seen useReducer in counters, forms, complex states, undo/redo, Context stores, and more.
But should you reach for it every time you need state?
Nope. Just like any tool, it shines in certain situations and is overkill in others.

When useReducer is a great choice

✅ Complex state logic — multiple related fields that need coordinated updates.
✅ Multiple ways to update the same state — actions centralize the rules.
✅ Predictability matters — every change goes through one function you can easily test.
✅ Shared state with Context — builds a mini global store without extra libraries.
✅ History tracking (undo/redo) — pure updates make state history easy to store.

When useState is probably better

🚫 Simple, independent values — no need to wrap a single toggle or input in reducer ceremony.
🚫 One-off state — if the update logic is one line, useState is simpler.
🚫 Highly local state — when only one small component cares, keep it local and easy.

A quick decision flow

Is your state update logic simple?
   → Yes → useState
   → No → Does your state have multiple related values?
       → No → useState
       → Yes → Do you want all update rules in one place?
           → Yes → useReducer
           → No → useState

React 19 and the Future

React 19 doesn’t change the fundamentals of useReducer,
but the mindset it teaches — centralized, predictable, pure updates — will carry forward into new features like useEvent and server actions.

If you master reducers now, you’ll be comfortable with more advanced state management approaches later, whether that’s Redux, Zustand, or entirely new patterns.

Wrap-Up

React’s useReducer isn’t “better” than useState — it’s just a different shape of tool. Think of it like switching from a hammer to a screwdriver: if you’re dealing with a nail, the hammer wins every time. If you’re dealing with a screw, the screwdriver will save you frustration.

Once your app’s logic starts getting messy, useReducer stops feeling like overkill and starts feeling like the grown-up, organized version of state management. With it you can:

  • Keep all update rules in one neat place
  • Make complex state predictable
  • Gain testability almost for free
  • Unlock powerful patterns with Context and beyond

So next time your useState code turns into a web of scattered updates, remember you’ve got another tool in the toolbox. And if anyone asks why you’re bothering with useReducer, you can smile and say:

“Because future-me deserves a cleaner codebase.”

👉 Coming up next: Don’t Misuse useRef in React: The Practical Guide You Actually Need

Follow me on DEV for future posts in this deep-dive series.
https://dev.to/a1guy
If it helped, leave a reaction (heart / bookmark) — it keeps me motivated to create more content
Want video demos? Subscribe on YouTube: @LearnAwesome


This content originally appeared on DEV Community and was authored by Ali Aslam