Skip to main content

useReducer

What is useReducer?

useReducer is a React hook for managing state that involves multiple sub-values or where the next state depends on the previous state in complex ways. It's an alternative to useState that scales better for non-trivial state logic.

It follows the reducer pattern: you dispatch an action, a reducer function calculates the next state, and the component re-renders.

const [state, dispatch] = useReducer(reducer, initialState);
  • state — current state value
  • dispatch — function to send actions
  • reducer — a pure function (state, action) => newState
  • initialState — the starting state

useState vs useReducer

ScenarioUse
Single value, simple updatesuseState
Multiple related valuesuseReducer
Next state depends on previoususeReducer
Complex update logicuseReducer
Shared logic across multiple state updatesuseReducer

Your First useReducer

A counter — the simplest example:

import { useReducer } from "react";

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 };
default:
throw new Error(`Unknown action: ${action.type}`);
}
}

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

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

The reducer is a pure function — it takes the current state and an action, and returns the new state. It never mutates state directly.

Always throw on unknown actions

The default: throw new Error(...) in the switch is intentional. It catches typos in action types immediately rather than silently returning stale state.

Actions with Payloads

Actions can carry data in a payload field:

function reducer(state, action) {
switch (action.type) {
case "set_name":
return { ...state, name: action.payload };
case "set_age":
return { ...state, age: action.payload };
case "reset":
return initialState;
default:
throw new Error(`Unknown action: ${action.type}`);
}
}

// Dispatching with payload
dispatch({ type: "set_name", payload: "Rizwan" });
dispatch({ type: "set_age", payload: 25 });

Real-World Example: Form State

Managing a multi-field form with useState means one useState call per field. With useReducer, all form state lives together:

const initialState = {
name: "",
email: "",
password: "",
errors: {},
isSubmitting: false,
};

function formReducer(state, action) {
switch (action.type) {
case "set_field":
return {
...state,
[action.field]: action.value,
errors: { ...state.errors, [action.field]: null },
};
case "set_errors":
return { ...state, errors: action.payload, isSubmitting: false };
case "submit_start":
return { ...state, isSubmitting: true, errors: {} };
case "submit_success":
return initialState;
default:
throw new Error(`Unknown action: ${action.type}`);
}
}

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

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

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

try {
await api.register(state);
dispatch({ type: "submit_success" });
} catch (err) {
dispatch({ type: "set_errors", payload: err.errors });
}
};

return (
<form onSubmit={handleSubmit}>
<input
name="name"
value={state.name}
onChange={handleChange}
placeholder="Name"
/>
{state.errors.name && <p className="error">{state.errors.name}</p>}

<input
name="email"
value={state.email}
onChange={handleChange}
placeholder="Email"
/>
{state.errors.email && <p className="error">{state.errors.email}</p>}

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

All the state transitions are named and centralized in the reducer — easy to read, test, and debug.

Combining useReducer with Context

useReducer + Context is the React-native alternative to Redux for global state. You saw the Context API pattern in the previous section — here's how to combine them:

src/context/CartContext.jsx
import { createContext, useContext, useReducer } from "react";

const CartContext = createContext(null);

export function cartReducer(state, action) {
switch (action.type) {
case "add_item": {
const existing = state.items.find((i) => i.id === action.payload.id);
if (existing) {
return {
...state,
items: state.items.map((i) =>
i.id === action.payload.id ? { ...i, qty: i.qty + 1 } : i
),
};
}
return { ...state, items: [...state.items, { ...action.payload, qty: 1 }] };
}

case "remove_item":
return {
...state,
items: state.items.filter((i) => i.id !== action.payload),
};

case "clear":
return { items: [] };

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

export function CartProvider({ children }) {
const [state, dispatch] = useReducer(cartReducer, { items: [] });
return (
<CartContext.Provider value={{ state, dispatch }}>
{children}
</CartContext.Provider>
);
}

export function useCart() {
const context = useContext(CartContext);
if (!context) throw new Error("useCart must be used inside <CartProvider>");
return context;
}

Usage in any component:

function ProductCard({ product }) {
const { dispatch } = useCart();

return (
<button onClick={() => dispatch({ type: "add_item", payload: product })}>
Add to Cart
</button>
);
}

function CartSummary() {
const { state, dispatch } = useCart();
const total = state.items.reduce((sum, i) => sum + i.price * i.qty, 0);

return (
<div>
<p>{state.items.length} items — ${total.toFixed(2)}</p>
<button onClick={() => dispatch({ type: "clear" })}>Clear Cart</button>
</div>
);
}

Testing Reducers

Since reducers are pure functions, they're trivial to test — no mocks, no setup:

import { cartReducer } from "./CartContext";

test("adds new item to cart", () => {
const state = { items: [] };
const action = { type: "add_item", payload: { id: 1, name: "Book", price: 20 } };
const result = cartReducer(state, action);
expect(result.items).toHaveLength(1);
expect(result.items[0].qty).toBe(1);
});

test("increments quantity for existing item", () => {
const state = { items: [{ id: 1, name: "Book", price: 20, qty: 1 }] };
const action = { type: "add_item", payload: { id: 1, name: "Book", price: 20 } };
const result = cartReducer(state, action);
expect(result.items[0].qty).toBe(2);
});

This testability is one of the biggest advantages of the reducer pattern.