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:
-
State lives in React — just like with
useState
. - When something happens (user clicks, form submits, API finishes loading), you call
dispatch(action)
. - React passes the current state and the action to your reducer function.
- The reducer returns a brand-new state object (not a mutation of the old one!).
- 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:
- 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.
- 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:
- The button calls
dispatch({ type: "increment" })
. - React hands the current state
{ count: 0 }
and the action{ type: "increment" }
tocounterReducer
. -
counterReducer
sees"increment"
and returns{ count: 1 }
. - 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:
- type → a string that describes what happened.
- 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:
- Flatten the shape if possible.
- 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
oruseLayoutEffect
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