AiTechWorlds
AiTechWorlds
useReducer is a hook that updates state by dispatching named actions to a single pure function, instead of calling separate setter functions for each change.
It works like a bank rather than a cash jar. With useState, every update reaches directly into the jar and changes the amount. With useReducer, you submit a labeled transaction — "DEPOSIT," "WITHDRAW" — and one central ledger function decides the resulting balance. That central function is easy to audit, test, and log, which is why it's the pattern behind Redux, Zustand, and most professional state management.
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>
);
}Switch from useState to useReducer when:
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>
);
}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>
);
}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?
→ useStateNext lesson: useMemo and useCallback — preventing expensive recalculations and unnecessary re-renders.
Get this course's notes on Telegram!
Free cheat sheets, summaries & practice exercises