useReducer for Complex State
useReducer for Complex State
useState is great for simple values. When state has multiple sub-values that change together, or when the next state depends on which action was taken, useReducer is cleaner and more maintainable. It's the pattern behind Redux, Zustand, and most professional state management.
The Core Idea
Instead of calling setCount(count + 1), you dispatch an action: dispatch({ type: "INCREMENT" }). A pure function (the reducer) calculates the next state based on the current state and the action.
import { useReducer } from "react";
// Define all possible state shapes
interface CounterState {
count: number;
step: number;
}
// Define all possible actions
type CounterAction =
| { type: "INCREMENT" }
| { type: "DECREMENT" }
| { type: "RESET" }
| { type: "SET_STEP"; step: number };
// Pure function: (state, action) => newState
function counterReducer(state: CounterState, action: CounterAction): CounterState {
switch (action.type) {
case "INCREMENT":
return { ...state, count: state.count + state.step };
case "DECREMENT":
return { ...state, count: state.count - state.step };
case "RESET":
return { ...state, count: 0 };
case "SET_STEP":
return { ...state, step: action.step };
}
}
function Counter() {
const [state, dispatch] = useReducer(counterReducer, { count: 0, step: 1 });
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>
<select onChange={e => dispatch({ type: "SET_STEP", step: Number(e.target.value) })}>
<option value="1">Step 1</option>
<option value="5">Step 5</option>
<option value="10">Step 10</option>
</select>
</div>
);
}
When to Reach for useReducer
Switch from useState to useReducer when:
- State has 3+ values that update together
- The new state depends on which action happened (not just the new value)
- You want to log, test, or debug state transitions explicitly
- Multiple event handlers set related state
Form with useReducer
A complex form with multiple fields, validation, and submission states:
interface FormState {
values: {
name: string;
email: string;
password: string;
};
errors: Partial<Record<"name" | "email" | "password" | "submit", string>>;
status: "idle" | "submitting" | "success" | "error";
}
type FormAction =
| { type: "SET_FIELD"; field: keyof FormState["values"]; value: string }
| { type: "SET_ERROR"; field: keyof FormState["errors"]; message: string }
| { type: "CLEAR_ERROR"; field: keyof FormState["errors"] }
| { type: "SET_STATUS"; status: FormState["status"] }
| { type: "RESET" };
const initialState: FormState = {
values: { name: "", email: "", password: "" },
errors: {},
status: "idle",
};
function formReducer(state: FormState, action: FormAction): FormState {
switch (action.type) {
case "SET_FIELD":
return {
...state,
values: { ...state.values, [action.field]: action.value },
};
case "SET_ERROR":
return {
...state,
errors: { ...state.errors, [action.field]: action.message },
};
case "CLEAR_ERROR":
return {
...state,
errors: { ...state.errors, [action.field]: undefined },
};
case "SET_STATUS":
return { ...state, status: action.status };
case "RESET":
return initialState;
}
}
function SignupForm() {
const [state, dispatch] = useReducer(formReducer, initialState);
function handleChange(field: keyof FormState["values"]) {
return (e: React.ChangeEvent<HTMLInputElement>) => {
dispatch({ type: "SET_FIELD", field, value: e.target.value });
dispatch({ type: "CLEAR_ERROR", field });
};
}
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
// Validate
let hasError = false;
if (!state.values.name.trim()) {
dispatch({ type: "SET_ERROR", field: "name", message: "Name is required" });
hasError = true;
}
if (!state.values.email.includes("@")) {
dispatch({ type: "SET_ERROR", field: "email", message: "Valid email required" });
hasError = true;
}
if (state.values.password.length < 8) {
dispatch({ type: "SET_ERROR", field: "password", message: "8+ characters required" });
hasError = true;
}
if (hasError) return;
dispatch({ type: "SET_STATUS", status: "submitting" });
try {
await registerUser(state.values);
dispatch({ type: "SET_STATUS", status: "success" });
} catch (err) {
dispatch({ type: "SET_ERROR", field: "submit", message: "Registration failed" });
dispatch({ type: "SET_STATUS", status: "error" });
}
}
if (state.status === "success") {
return <p>Account created! Check your email.</p>;
}
return (
<form onSubmit={handleSubmit}>
<input
value={state.values.name}
onChange={handleChange("name")}
placeholder="Your name"
/>
{state.errors.name && <p className="error">{state.errors.name}</p>}
{/* ... other fields ... */}
{state.errors.submit && <p className="error">{state.errors.submit}</p>}
<button type="submit" disabled={state.status === "submitting"}>
{state.status === "submitting" ? "Creating account..." : "Sign Up"}
</button>
</form>
);
}
Reducer + Context (Redux-like Pattern)
Combine useReducer with Context for app-level state:
// src/context/todos.tsx
import { createContext, useContext, useReducer, ReactNode } from "react";
interface Todo {
id: string;
text: string;
completed: boolean;
}
type TodoAction =
| { type: "ADD"; text: string }
| { type: "TOGGLE"; id: string }
| { type: "DELETE"; id: string }
| { type: "CLEAR_COMPLETED" };
function todoReducer(todos: Todo[], action: TodoAction): Todo[] {
switch (action.type) {
case "ADD":
return [...todos, { id: crypto.randomUUID(), text: action.text, completed: false }];
case "TOGGLE":
return todos.map(t => t.id === action.id ? { ...t, completed: !t.completed } : t);
case "DELETE":
return todos.filter(t => t.id !== action.id);
case "CLEAR_COMPLETED":
return todos.filter(t => !t.completed);
}
}
const TodoContext = createContext<{
todos: Todo[];
dispatch: React.Dispatch<TodoAction>;
} | null>(null);
export function TodoProvider({ children }: { children: ReactNode }) {
const [todos, dispatch] = useReducer(todoReducer, []);
return (
<TodoContext.Provider value={{ todos, dispatch }}>
{children}
</TodoContext.Provider>
);
}
export function useTodos() {
const context = useContext(TodoContext);
if (!context) throw new Error("useTodos must be inside TodoProvider");
return context;
}
// In any component:
function AddTodoForm() {
const { dispatch } = useTodos();
const [text, setText] = useState("");
function handleSubmit(e: React.FormEvent) {
e.preventDefault();
if (text.trim()) {
dispatch({ type: "ADD", text });
setText("");
}
}
return (
<form onSubmit={handleSubmit}>
<input value={text} onChange={e => setText(e.target.value)} />
<button type="submit">Add</button>
</form>
);
}
function TodoList() {
const { todos, dispatch } = useTodos();
return (
<ul>
{todos.map(todo => (
<li key={todo.id}>
<span
onClick={() => dispatch({ type: "TOGGLE", id: todo.id })}
style={{ textDecoration: todo.completed ? "line-through" : "none" }}
>
{todo.text}
</span>
<button onClick={() => dispatch({ type: "DELETE", id: todo.id })}>×</button>
</li>
))}
</ul>
);
}
useState vs useReducer Decision Guide
Does the update depend only on a simple new value?
→ useState
Does the component have 3+ state values that change together?
→ useReducer
Do multiple event handlers set state that affects the same logic?
→ useReducer (centralize the transitions)
Do you want to test state transitions in isolation?
→ useReducer (reducers are pure functions — easy to unit test)
Is it a simple toggle, counter, or string?
→ useState
Next lesson: useMemo and useCallback — preventing expensive recalculations and unnecessary re-renders.
Get this course's notes on Telegram!
Free cheat sheets, summaries & practice exercises