useReducer is an alternative to useState for managing complex state with multiple transitions. It takes a reducer function and an initial state, and returns the current state along with a dispatch function. Instead of calling individual setters, you dispatch action objects that describe what happened, and the reducer function decides how state should change in response. This pattern centralizes all state logic in one predictable function, making complex state flows easier to understand and test.
import { useReducer } from 'react';
function reducer(state, action) {
switch (action.type) {
case 'increment': return { count: state.count + 1 };
case 'decrement': return { count: state.count - 1 };
default: return state;
}
}
function Counter() {
const [state, dispatch] = useReducer(reducer, { count: 0 });
return <button onClick={() => dispatch({ type: 'increment' })}>{state.count}</button>;
}
useReducer returns the current state and a dispatch function to send actions to the reducer.
Why it matters: useReducer keeps all state transition logic in one place, making complex state changes easier to understand and test.
Real applications: Shopping carts, form wizards, undo/redo systems, any state with multiple things that can happen to it.
Common mistakes: Mutating state directly inside the reducer instead of returning a new object — this breaks React's rendering system.
action argument is typically an object with a type string identifying what happened and an optional payload with additional data. A switch statement on action.type is the conventional way to handle different action types and return the appropriate next state.
function todoReducer(state, action) {
switch (action.type) {
case 'add':
return [...state, { id: Date.now(), text: action.text, done: false }];
case 'toggle':
return state.map(t =>
t.id === action.id ? { ...t, done: !t.done } : t
);
case 'delete':
return state.filter(t => t.id !== action.id);
default:
throw new Error('Unknown action: ' + action.type);
}
}
Always return new state objects — never mutate the existing state. Throwing on unknown actions helps catch typos early.
Why it matters: A well-written reducer is a pure function — easy to test in isolation and predictable because the same input always produces the same output.
Real applications: Handling add/remove/update/clear for a cart, managing loading/success/error state for an API call.
Common mistakes: Using if/else instead of a switch statement — switch is the conventional pattern for reducers and makes cases easier to scan.
dispatch function sends action objects to the reducer to trigger state updates. Actions are plain JavaScript objects that conventionally have a type property describing the event and an optional payload with the data needed to compute the new state. Calling dispatch is like calling a setter in useState — it schedules a re-render with the new state returned by the reducer. dispatch is stable across renders and does not need to be included in useCallback or useEffect dependency arrays.
const [state, dispatch] = useReducer(todoReducer, []);
// Dispatch various actions
dispatch({ type: 'add', text: 'Learn React' });
dispatch({ type: 'toggle', id: 1 });
dispatch({ type: 'delete', id: 1 });
dispatch is stable across re-renders, so it is safe to pass to child components or include in dependency arrays without causing extra renders.
Why it matters: Dispatching an action with a type and payload is a clear, explicit way to describe what happened and what should change.
Real applications: Clicking "Add to Cart" dispatches an ADD_ITEM action; clicking "Remove" dispatches REMOVE_ITEM.
Common mistakes: Passing an object with extra fields in the action and forgetting to handle them in the reducer, causing silent bugs.
useReducer over useState when state logic is complex, involves multiple related sub-values that update together, or when the next state depends on the previous one in non-trivial ways. useState is the right choice for simple, independent values like a boolean toggle or a string input. Switch to useReducer when you find yourself writing multiple related useState calls that change together, when updates involve complex conditions, or when state transitions need to be predictable and testable in isolation.
// useState — simple, independent values
const [name, setName] = useState('');
const [age, setAge] = useState(0);
// useReducer — complex, related state transitions
const [state, dispatch] = useReducer(formReducer, {
name: '', age: 0, errors: {}, isSubmitting: false
});
dispatch({ type: 'SET_FIELD', field: 'name', value: 'Alice' });
dispatch({ type: 'SUBMIT' });
useReducer centralizes logic, makes state transitions explicit, and is easier to test since reducers are pure functions.
Why it matters: Knowing when to switch from useState to useReducer keeps your code organized as complexity grows.
Real applications: Use useState for a toggle or a counter; use useReducer for a multi-step form, cart, or any state with 3+ related fields that change together.
Common mistakes: Using useState with a large object and spreading the whole state on every update instead of switching to useReducer.
useMemo is a hook that caches the result of an expensive calculation between renders. It accepts a factory function and a dependency array — the factory only re-runs when one of the listed dependencies changes, otherwise the previous cached result is returned. Without useMemo, the calculation runs on every render even if its inputs are identical. Use it for operations like sorting, filtering, or aggregating large arrays where recomputing on every render would be noticeably slow.
import { useMemo } from 'react';
function ProductList({ products, filter }) {
const filtered = useMemo(() => {
return products.filter(p => p.category === filter);
}, [products, filter]);
return filtered.map(p => <div key={p.id}>{p.name}</div>);
}
Use useMemo for computationally expensive operations. Don't memoize trivial calculations — the overhead of memoization can outweigh the benefit.
Why it matters: useMemo skips re-computing an expensive value if the inputs haven't changed, saving CPU time on heavy operations.
Real applications: Sorting or filtering a large list, running complex math for a chart, deriving a formatted value from raw data.
Common mistakes: Wrapping everything in useMemo "just in case" — memoization has its own overhead and can make simple code harder to read.
useCallback memoizes a function reference so it stays stable across renders, while useMemo memoizes a computed value. Both take a factory and a dependency array, and both return the cached result when dependencies haven't changed. You can express useCallback(fn, deps) as useMemo(() => fn, deps) — they are effectively the same mechanism applied to different types. Use useCallback when the cached item is a function and useMemo when it is a computed value.
// useMemo — caches a value
const sortedList = useMemo(() => items.sort(compareFn), [items]);
// useCallback — caches a function
const handleClick = useCallback((id) => {
setSelected(id);
}, []);
// useCallback(fn, deps) is equivalent to useMemo(() => fn, deps)
Use useCallback when passing callbacks to memoized child components to prevent unnecessary re-renders caused by new function references.
Why it matters: Functions created inside a component are recreated on every render. useCallback keeps the same reference so memoized children don't re-render unnecessarily.
Real applications: Passing an onClick handler to a memoized list item, passing a fetch function to a child that has useEffect dependencies.
Common mistakes: Using useCallback without React.memo on the child — the callback is stable but the child re-renders anyway, making the memoization pointless.
React.memo is a higher-order component that wraps a functional component and skips re-rendering when its props haven't changed. On every render, React performs a shallow comparison of the previous and current props — if all props are equal by reference, the previous render output is reused. For React.memo to work fully, object and function props passed to the wrapped component must also be stabilized with useMemo and useCallback in the parent, otherwise new references defeat the comparison.
const ExpensiveList = React.memo(function ExpensiveList({ items, onSelect }) {
console.log('Rendering list');
return items.map(item =>
<div key={item.id} onClick={() => onSelect(item.id)}>{item.name}</div>
);
});
// Parent must stabilize props:
const handleSelect = useCallback((id) => { /* ... */ }, []);
<ExpensiveList items={items} onSelect={handleSelect} />
React.memo only works if both value props and function props have stable references. Pair it with useCallback and useMemo.
Why it matters: React.memo skips re-rendering a component if its props haven't changed, cutting wasted render cycles in large component trees.
Real applications: Memoizing list items in a large list, memoizing an expensive chart component that only needs to re-render when its data changes.
Common mistakes: Applying React.memo without stabilizing function props with useCallback — the component still re-renders because the function reference changes every time.
// ✅ Worth memoizing — expensive computation
const chart = useMemo(() => generateChartData(rawData), [rawData]);
// ✅ Worth memoizing — passed to React.memo child
const onClick = useCallback(() => save(id), [id]);
// ❌ Not worth memoizing — trivial computation
const fullName = useMemo(() => first + ' ' + last, [first, last]);
// Just do: const fullName = first + ' ' + last;
Premature memoization adds complexity without benefit. Profile first and memoize only where you observe actual performance issues.
Why it matters: Over-memoizing adds code complexity, maintenance cost, and sometimes makes things slower — not faster.
Real applications: First measure with React DevTools Profiler; only memoize the components and values that are actually slow.
Common mistakes: Adding useMemo and useCallback everywhere "to be safe" without measuring whether those components are actually causing performance problems.
===) to determine if props or hook dependencies have changed between renders. A new object literal or a new arrow function created during render will fail the equality check even if its contents are identical, because different object references are never ===. This is why passing { style: { color: 'red' } } as a prop creates a new object every render, breaking memoization. The solution is to stabilize references with useMemo for objects and useCallback for functions.
// New object every render — breaks React.memo
<Child style={{ color: 'red' }} />
// Stable reference — React.memo works
const style = useMemo(() => ({ color: 'red' }), []);
<Child style={style} />
// New function every render
<Child onClick={() => doSomething()} />
// Stable function
const onClick = useCallback(() => doSomething(), []);
Understanding referential equality is key to making React.memo, useMemo, useCallback, and useEffect dependency arrays work correctly.
Why it matters: React compares props and dependencies by reference, not by value. Two objects with the same content are not equal unless they're the same reference.
Real applications: Any time you pass objects or arrays as props to memoized components, or include them in useEffect dependency arrays.
Common mistakes: Including an object or array in a useEffect dependency array without memoizing it — a new reference is created on every render, causing the effect to run every time.
// 1. Identify slow renders with React DevTools Profiler
// 2. Prevent unnecessary re-renders
const MemoChild = React.memo(ChildComponent);
// 3. Memoize expensive computations
const result = useMemo(() => heavyCalc(data), [data]);
// 4. Stabilize callback references
const handler = useCallback(() => { /* ... */ }, [dep]);
// 5. Virtualize long lists
import { FixedSizeList } from 'react-window';
// 6. Code-split with React.lazy
const Page = React.lazy(() => import('./HeavyPage'));
Avoid premature optimization. Measure first, then apply targeted fixes. React is fast by default — most apps need very few manual optimizations.
Why it matters: Understanding the optimization toolkit helps you fix real performance problems without guessing or over-engineering.
Real applications: Profiling a slow dashboard with React DevTools, finding a memoization gap in a long product list, fixing a slow initial load with code splitting.
Common mistakes: Optimizing before measuring — often the bottleneck is not where you think it is, and premature optimization wastes time.
useReducer accepts an optional third argument — an init function — that computes the initial state lazily on mount instead of evaluating the initial value on every call. Without the init function, if you wrote useReducer(reducer, expensiveCalc()), the expensive function would run on every render even though the result is only used for the initial render. With the init function (useReducer(reducer, arg, initFn)), initFn(arg) runs only once. This mirrors the lazy initializer pattern in useState(() => value).
function init(initialCount) {
// Runs only once on mount — useful for expensive setup
return { count: initialCount, history: [] };
}
function Counter({ startCount }) {
const [state, dispatch] = useReducer(reducer, startCount, init);
// state = { count: startCount, history: [] }
const reset = () => dispatch({ type: 'reset', payload: startCount });
return <button onClick={reset}>Reset</button>;
}
The init function receives the second argument of useReducer as its input. It is called once on mount, avoiding expensive recalculation on every render.
Why it matters: Lazy initialization is useful when the initial state is expensive to compute — like reading from localStorage or doing a large calculation.
Real applications: Loading saved form state from localStorage on first mount, computing an initial state from a large API response.
Common mistakes: Computing expensive state inline in the second argument of useReducer — it runs on every render instead of only once.
useMemo caches a computed value — the result of calling a function. useCallback caches a function reference itself. Both accept a factory and dependency array, and both return the cached item when dependencies haven't changed. You could write useCallback(fn, deps) as useMemo(() => fn, deps); they are the same mechanism for different item types. Use useCallback when the cached thing will be called (like an event handler), and useMemo when the cached thing will be read (like a computed list).
const data = [/* large array */];
// useMemo — cache the expensive result
const sortedData = useMemo(
() => [...data].sort((a, b) => a.age - b.age),
[data]
);
// useCallback — cache the function so child doesn't re-render
const handleDelete = useCallback((id) => {
setData(prev => prev.filter(item => item.id !== id));
}, []); // no deps — function never changes
// useCallback(fn, deps) === useMemo(() => fn, deps)
Use useCallback when passing functions to React.memo children. Use useMemo for expensive derivations like sorting, filtering, or complex calculations.
Why it matters: Choosing the right hook for the situation keeps your code clear — one memoizes a function, the other memoizes a value.
Real applications: useCallback for an onClick passed to a memoized child; useMemo for a filtered and sorted list derived from raw data.
Common mistakes: Using useMemo to memoize a function — it will work, but useCallback is the semantic and conventional choice for functions.
useReducer shines for forms with many fields because all form state lives in one object and every change maps to a named action type instead of scattered individual setters. It naturally handles async form submission states like loading, success, and error with dedicated action types. Validating all fields at once on submit is also clean — one dispatch with a 'VALIDATE' action can compute and set all error messages simultaneously. The form reducer is also straightforward to unit test in isolation without mounting any component.
const initialState = { name: '', email: '', loading: false, error: null };
function formReducer(state, action) {
switch (action.type) {
case 'SET_FIELD':
return { ...state, [action.field]: action.value };
case 'SUBMIT':
return { ...state, loading: true, error: null };
case 'SUCCESS':
return { ...initialState }; // reset form
case 'ERROR':
return { ...state, loading: false, error: action.error };
default: return state;
}
}
function MyForm() {
const [state, dispatch] = useReducer(formReducer, initialState);
const handleChange = (e) =>
dispatch({ type: 'SET_FIELD', field: e.target.name, value: e.target.value });
}
All state transitions are explicit and centralized in the reducer — much cleaner than managing 5 separate useState calls for a complex form.
Why it matters: useReducer makes it easy to handle complex form state like validation, touched fields, and submission status as a single unit.
Real applications: Multi-field forms with validation states, multi-step onboarding forms, forms that reset on cancel.
Common mistakes: Managing each form field with its own useState — fine for simple forms but gets messy when you need coordinated updates across fields.
useReducer with Context API creates a scalable, Redux-like pattern without external dependencies. The dispatch function is stable across renders so it can be placed in a dedicated context that only components needing to modify state subscribe to, while a separate context holds the state for components that only need to read it. This split means action-only components don't re-render when state changes. It's lighter than Redux but provides the same predictable, centralized state management model.
const DispatchContext = createContext(null);
const StateContext = createContext(null);
function AppProvider({ children }) {
const [state, dispatch] = useReducer(appReducer, initialState);
return (
<StateContext.Provider value={state}>
<DispatchContext.Provider value={dispatch}>
{children}
</DispatchContext.Provider>
</StateContext.Provider>
);
}
// Any child can dispatch without receiving it as a prop
function DeepChild() {
const dispatch = useContext(DispatchContext);
return <button onClick={() => dispatch({ type: 'LOGOUT' })}>Log out</button>;
}
Separating state and dispatch into two contexts prevents components that only dispatch from re-rendering when state changes.
Why it matters: Combining useReducer with Context gives you a Redux-like pattern without external dependencies — great for medium-complexity apps.
Real applications: Cart management, authentication state, a settings panel with multiple toggle options.
Common mistakes: Passing both state and dispatch in one context object — dispatch-only components re-render every time state changes, even though they don't need the state.