AiTechWorlds
AiTechWorlds
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.
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?
→ 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