useReducer is an alternative to multiple useState calls for complex state with many related pieces that transition together. Instead of setIsLoading(true); setError(null); setUser(data), you dispatch a single action: dispatch({ type: "LOGIN_SUCCESS", payload: data }). A pure reducer function handles all transitions in one place, making state changes predictable and easy to trace. For an auth context with loading, error, user, and token states, a reducer often produces cleaner code than five separate useState hooks.
useReducer Auth Context
import { createContext, useContext, useReducer, useEffect, useMemo } from "react";
import { authApi } from "@/services/auth";
// ── State shape ───────────────────────────────────────────────────────────────
const initialState = {
user: null,
isLoading: true, // checking stored token on mount
error: null,
};
// ── Reducer — pure function: (state, action) => newState ─────────────────────
function authReducer(state, action) {
switch (action.type) {
case "AUTH_INIT": return { ...state, isLoading: true, error: null };
case "AUTH_SUCCESS": return { user: action.payload, isLoading: false, error: null };
case "AUTH_FAILURE": return { user: null, isLoading: false, error: action.payload };
case "AUTH_LOGOUT": return { user: null, isLoading: false, error: null };
case "AUTH_RESTORE": return { user: action.payload, isLoading: false, error: null };
case "AUTH_NO_TOKEN": return { user: null, isLoading: false, error: null };
default: return state;
}
}
// ── Provider ──────────────────────────────────────────────────────────────────
const AuthContext = createContext(null);
export function AuthProvider({ children }) {
const [state, dispatch] = useReducer(authReducer, initialState);
// Validate stored token on mount
useEffect(() => {
const token = localStorage.getItem("access_token");
if (!token) {
dispatch({ type: "AUTH_NO_TOKEN" });
return;
}
authApi.me()
.then((user) => dispatch({ type: "AUTH_RESTORE", payload: user }))
.catch(() => {
localStorage.removeItem("access_token");
localStorage.removeItem("refresh_token");
dispatch({ type: "AUTH_NO_TOKEN" });
});
}, []);
async function login(email, password) {
dispatch({ type: "AUTH_INIT" });
try {
const { access_token, refresh_token } = await authApi.login(email, password);
localStorage.setItem("access_token", access_token);
localStorage.setItem("refresh_token", refresh_token);
const user = await authApi.me();
dispatch({ type: "AUTH_SUCCESS", payload: user });
return user;
} catch (err) {
dispatch({ type: "AUTH_FAILURE", payload: err.message });
throw err;
}
}
async function logout() {
try { await authApi.logout(localStorage.getItem("refresh_token")); } catch {}
localStorage.removeItem("access_token");
localStorage.removeItem("refresh_token");
dispatch({ type: "AUTH_LOGOUT" });
}
const value = useMemo(() => ({
...state,
isLoggedIn: Boolean(state.user),
login,
logout,
}), [state]); // eslint-disable-line react-hooks/exhaustive-deps
return (
<AuthContext.Provider value={value}>
{children}
</AuthContext.Provider>
);
}
export const useAuth = () => {
const ctx = useContext(AuthContext);
if (!ctx) throw new Error("useAuth must be inside AuthProvider");
return ctx;
};
Note: The reducer function must be pure — it must not cause side effects, mutate its arguments, or produce different output for the same input. This is what makes reducers predictable and testable: you can test
authReducer(initialState, { type: "AUTH_SUCCESS", payload: user }) without any mocking and verify the exact resulting state. Side effects (API calls, localStorage writes) stay in the action creator functions, not in the reducer.Tip: Define action type strings as constants to catch typos:
const AUTH_ACTIONS = { INIT: "AUTH_INIT", SUCCESS: "AUTH_SUCCESS", ... }. Then dispatch as dispatch({ type: AUTH_ACTIONS.SUCCESS, payload: user }). A typo in a string action type silently falls through to the reducer’s default case and returns unchanged state — very hard to debug. With constants, a typo is caught immediately by the linter as an undefined property.Warning:
useReducer is not always better than useState — it introduces more boilerplate (action types, a reducer function). Use it when you have 3+ related state variables that always update together, when state transitions have complex logic that is clearer as explicit cases, or when you want to write pure unit tests for state update logic. For simple boolean toggles or a single string value, useState is cleaner.Testing a Reducer
// Pure function test — no mocking needed
import { authReducer } from "@/context/AuthContext";
test("AUTH_SUCCESS sets user and clears loading", () => {
const mockUser = { id: 1, name: "Alice" };
const nextState = authReducer(
{ user: null, isLoading: true, error: null },
{ type: "AUTH_SUCCESS", payload: mockUser }
);
expect(nextState).toEqual({ user: mockUser, isLoading: false, error: null });
});
test("AUTH_LOGOUT clears user and tokens", () => {
const nextState = authReducer(
{ user: { id: 1 }, isLoading: false, error: null },
{ type: "AUTH_LOGOUT" }
);
expect(nextState.user).toBeNull();
});
useState vs useReducer Decision Guide
| Situation | Use |
|---|---|
| 1–2 independent values | useState |
| 3+ related values that update together | useReducer |
| Complex conditional update logic | useReducer |
| Need to test state transitions in isolation | useReducer |
| Simple toggle or text input | useState |