Follow AiTechWorlds on LinkedIn for professional AI content!Follow Now →
18 minLesson 8 of 33
Advanced React Patterns

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:

  1. State has 3+ values that update together
  2. The new state depends on which action happened (not just the new value)
  3. You want to log, test, or debug state transitions explicitly
  4. 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

Get Notes Free →
!