useReducer vs useState.
When state transitions deserve a reducer.
Both manage state. Both trigger re-renders. The difference is how the state changes. useState says "set this value." useReducer says "handle this action." For simple state, useState is fine. For complex state with transitions, invariants, or testable logic, useReducer is cleaner. This page shows when each wins.
Short answer: useState for 1-2 independent values. useReducer for 3+ related values that change together, or state that transitions between defined states (loading → success → error). The signals: (1) many useState calls that always change together, (2) complex update logic you want to test, (3) state with a clear set of allowed transitions. It's not about performance — it's about readability and correctness.
✅ Solution 1 — The same counter, two ways
When useState is simpler and clearer.
For a simple counter, useState wins. The state is one number. The operations are increment, decrement, reset. There's no transition logic, no invariants, no testable reducer. Adding useReducer would be over-engineering.
import { useState, useReducer } from 'react'; // ─── useState — simpler, faster to write ───── function CounterWithState() { const [count, setCount] = useState(0); return (); } // ─── useReducer — same thing, more ceremony type Action = { type: 'increment' } | { type: 'decrement' } | { type: 'reset' }; function reducer(state: number, action: Action): number { switch (action.type) { case 'increment': return state + 1; case 'decrement': return state - 1; case 'reset': return 0; } } function CounterWithReducer() { const [count, dispatch] = useReducer(reducer, 0); return ({count}
); } // ─── The verdict ───────────────────────────── // // For this counter, useState is clearly better: // - Less code (7 lines vs 20) // - No action union to define // - No reducer to test // - More direct — "setCount(c => c + 1)" // // But the reducer version has hidden advantages: // - The reducer is a pure function → easy to unit-test // - All state transitions visible in one place // - Adding a new action is mechanical // - The dispatch API is stable → safe in callbacks // // For a simple counter? useState wins. Simple is good. // For a to-do app with undo? useReducer wins. See next. console.log(CounterWithState, reducer, CounterWithReducer);{count}
import { useState, useReducer } from 'react'; function CounterWithState() { const [count, setCount] = useState(0); return React.createElement('div', null, React.createElement('p', null, count), React.createElement('button', { onClick: () => setCount(c => c + 1) }, '+1'), React.createElement('button', { onClick: () => setCount(c => c - 1) }, '-1'), React.createElement('button', { onClick: () => setCount(0) }, 'Reset') ); } function reducer(state, action) { switch (action.type) { case 'increment': return state + 1; case 'decrement': return state - 1; case 'reset': return 0; } } function CounterWithReducer() { const [count, dispatch] = useReducer(reducer, 0); return React.createElement('div', null, React.createElement('p', null, count), React.createElement('button', { onClick: () => dispatch({ type: 'increment' }) }, '+1'), React.createElement('button', { onClick: () => dispatch({ type: 'decrement' }) }, '-1'), React.createElement('button', { onClick: () => dispatch({ type: 'reset' }) }, 'Reset') ); } console.log(CounterWithState, reducer, CounterWithReducer);
useState = direct
setCount(c => c + 1) — direct setter. One value, one operation. Less code, less indirection.
useReducer = indirect
dispatch({ type: 'increment' }) — the reducer decides the next state. More code, more structure.
For simple counters
useState wins. No reason for the extra ceremony — the direct form is clearer.
Both re-render the same
Performance is essentially identical. Choose based on readability and testability, not speed.
✅ Solution 2 — When useReducer wins
Complex state with transitions, invariants, or testable logic.
When the state has multiple related fields that transition together — a counter with undo history, a form with loading/error/success, a to-do app with filters — useReducer becomes clearer than multiple useState calls. The reducer centralizes all transitions in one pure function, which you can test in isolation.
import { useReducer } from 'react'; // ─── State with history + undo ─────────────── interface State { count: number; history: number[]; } type Action = | { type: 'increment' } | { type: 'decrement' } | { type: 'add'; by: number } | { type: 'reset' } | { type: 'undo' }; const initialState: State = { count: 0, history: [] }; function reducer(state: State, action: Action): State { switch (action.type) { case 'increment': { const next = state.count + 1; return { count: next, history: [...state.history, state.count] }; } case 'decrement': { const next = state.count - 1; return { count: next, history: [...state.history, state.count] }; } case 'add': { const next = state.count + action.by; return { count: next, history: [...state.history, state.count] }; } case 'reset': return { count: 0, history: [...state.history, state.count] }; case 'undo': { if (state.history.length === 0) return state; const prev = state.history[state.history.length - 1]; return { count: prev, history: state.history.slice(0, -1) }; } } } function CounterWithHistory() { const [state, dispatch] = useReducer(reducer, initialState); return (); } // ─── State machine — loading / success / error type FetchStateCount: {state.count}
History: {state.history.join(', ')}
= | { status: 'idle' } | { status: 'loading' } | { status: 'success'; data: T } | { status: 'error'; message: string }; type FetchAction = | { type: 'fetch' } | { type: 'success'; data: T } | { type: 'error'; message: string } | { type: 'reset' }; function fetchReducer (state: FetchState , action: FetchAction ): FetchState { switch (action.type) { case 'fetch': return { status: 'loading' }; case 'success': return { status: 'success', data: action.data }; case 'error': return { status: 'error', message: action.message }; case 'reset': return { status: 'idle' }; } } // ─── Testable reducer ──────────────────────── // // Because reducer is a pure function, you can test it // without rendering React: // // expect(reducer(initial, { type: 'increment' })) // .toEqual({ count: 1, history: [0] }) // // expect(reducer({ count: 5, history: [4] }, { type: 'undo' })) // .toEqual({ count: 4, history: [] }) // // This is the killer feature of useReducer. State // transitions become unit-testable logic, not // buried-in-JSX behavior. console.log(reducer, CounterWithHistory, fetchReducer);
import { useReducer } from 'react'; const initialState = { count: 0, history: [] }; function reducer(state, action) { switch (action.type) { case 'increment': { const next = state.count + 1; return { count: next, history: [...state.history, state.count] }; } case 'decrement': { const next = state.count - 1; return { count: next, history: [...state.history, state.count] }; } case 'add': { const next = state.count + action.by; return { count: next, history: [...state.history, state.count] }; } case 'reset': return { count: 0, history: [...state.history, state.count] }; case 'undo': { if (state.history.length === 0) return state; const prev = state.history[state.history.length - 1]; return { count: prev, history: state.history.slice(0, -1) }; } } } function CounterWithHistory() { const [state, dispatch] = useReducer(reducer, initialState); return React.createElement('div', null, React.createElement('p', null, 'Count: ', state.count), React.createElement('p', null, 'History: ', state.history.join(', ')), React.createElement('button', { onClick: () => dispatch({ type: 'increment' }) }, '+1'), React.createElement('button', { onClick: () => dispatch({ type: 'decrement' }) }, '-1'), React.createElement('button', { onClick: () => dispatch({ type: 'add', by: 10 }) }, '+10'), React.createElement('button', { onClick: () => dispatch({ type: 'undo' }) }, 'Undo'), React.createElement('button', { onClick: () => dispatch({ type: 'reset' }) }, 'Reset') ); } function fetchReducer(state, action) { switch (action.type) { case 'fetch': return { status: 'loading' }; case 'success': return { status: 'success', data: action.data }; case 'error': return { status: 'error', message: action.message }; case 'reset': return { status: 'idle' }; } } console.log(reducer, CounterWithHistory, fetchReducer);
Many related fields
Count + history are inseparable. One dispatch updates both consistently. With useState, you'd have to remember to update both.
State transitions
Idle → loading → success/error is a state machine. The reducer defines allowed transitions; impossible ones become compile errors.
Testable in isolation
reducer(state, action) is a pure function. Test it without rendering React. This is the biggest win for complex state.
Stable dispatch
dispatch never changes identity. Safe to pass to children or omit from dependency arrays.
✅ Solution 3 — The decision rule
A checklist for choosing between them.
The choice isn't about performance or preference — it's about state complexity. Here's the checklist. If the answer to any of these is "yes," useReducer is probably right.
import { useState, useReducer } from 'react'; // ─── CHECKLIST ─────────────────────────────── // // Use useReducer when ANY of these are true: // // ✅ 3+ useState calls that always change together // (e.g. loading + data + error) // // ✅ State has defined transitions // (e.g. idle → loading → success | error) // // ✅ Undo/redo or history matters // (e.g. counter with undo, text editor with back) // // ✅ Update logic has invariants // (e.g. selectedCount can't exceed items.length) // // ✅ You want to unit-test state transitions // (reducers are pure functions) // // ✅ State updates are complex // (e.g. conditional logic, sequences, guards) // // Use useState when ALL of these are true: // // ✅ 1-2 independent values // ✅ Simple, direct updates // ✅ No transitions or invariants // ✅ Testing the component tests the state // // ───────────────────────────────────────────── // ─── Example 1: useState — simple and clear ── function ToggleButton() { const [isOpen, setIsOpen] = useState(false); return ; } // One boolean. One operation. useState wins. // ─── Example 2: useReducer — form with validation interface FormState { values: { name: string; email: string }; errors: { name?: string; email?: string }; touched: { name: boolean; email: boolean }; submitting: boolean; } type FormAction = | { type: 'change'; field: keyof FormState['values']; value: string } | { type: 'blur'; field: keyof FormState['touched'] } | { type: 'submit' } | { type: 'success' } | { type: 'error'; errors: FormState['errors'] }; function formReducer(state: FormState, action: FormAction): FormState { switch (action.type) { case 'change': return { ...state, values: { ...state.values, [action.field]: action.value } }; case 'blur': return { ...state, touched: { ...state.touched, [action.field]: true } }; case 'submit': return { ...state, submitting: true, errors: {} }; case 'success': return { ...state, submitting: false }; case 'error': return { ...state, submitting: false, errors: action.errors }; } } function ComplexForm() { const [state, dispatch] = useReducer(formReducer, { values: { name: '', email: '' }, errors: {}, touched: { name: false, email: false }, submitting: false }); // 4 fields on FormState, 5 action types, complex transitions // → useState would need 4+ separate calls + sync issues // → useReducer keeps everything in one reducer, testable return; } // ─── Example 3: useState — data fetching (simple) function SimpleFetch({ url }: { url: string }) { const [data, setData] = useState<unknown>(null); const [loading, setLoading] = useState(true); const [error, setError] = useState<string | null>(null); // For a single fetch in a single component, 3 useState calls is fine. // The states are independent in practice (only one is "on" at a time). // If you extract this to useFetch → useReducer becomes better (discriminated union). return null; } // ─── The progressive threshold ─────────────── // // Start with useState. Add complexity slowly. // // 1-2 states, simple → useState // 3+ states, related → useReducer // Complex transitions → useReducer // Extractable to a hook → often useReducer (discriminated union) // Needs unit testing → useReducer (pure reducer) // // If you find yourself writing: // setLoading(true); setData(null); setError(null); // ... fetch ... // setLoading(false); setData(data); // // That's 4 setters that always change together. // That's a signal. Use useReducer. console.log(ToggleButton, formReducer, ComplexForm, SimpleFetch);
import { useState, useReducer } from 'react'; function ToggleButton() { const [isOpen, setIsOpen] = useState(false); return React.createElement('button', { onClick: () => setIsOpen(o => !o) }, isOpen ? 'Open' : 'Closed'); } function formReducer(state, action) { switch (action.type) { case 'change': return { ...state, values: { ...state.values, [action.field]: action.value } }; case 'blur': return { ...state, touched: { ...state.touched, [action.field]: true } }; case 'submit': return { ...state, submitting: true, errors: {} }; case 'success': return { ...state, submitting: false }; case 'error': return { ...state, submitting: false, errors: action.errors }; } } function ComplexForm() { const [state, dispatch] = useReducer(formReducer, { values: { name: '', email: '' }, errors: {}, touched: { name: false, email: false }, submitting: false }); return React.createElement('form'); } function SimpleFetch({ url }) { const [data, setData] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); return null; } console.log(ToggleButton, formReducer, ComplexForm, SimpleFetch);
Start simple
Use useState. Add complexity only when it's earned. Refactoring useState → useReducer later is easy.
Watch for grouped setters
setLoading(true); setData(null); setError(null); — 4 setters that always change together = reducer signal.
Reducers are pure
Same input → same output. No side effects. That's what makes them testable and predictable.
Both are valid
Neither is "better." Both are correct for different codebases. Pick the one that reads clearer for the specific state.
The best refactor signal: your component has 4+ useState calls for one logical concept. Loading, data, error, lastUpdated, retryCount — five useState calls that always change together. That's a reducer. The reducer gives you one state object, one action type per transition, and a testable pure function. Same for form state — values + errors + touched + submitting in five useState calls is a nightmare; one reducer is clean. The rule: if the states are related, bundle them.
⚠️ Common mistakes
The useReducer mistakes that cost real time.
Over-engineering. Fix: useState for simple state — one value, one operation.
state.count++ — no re-render. Fix: return { ...state, count: state.count + 1 }.
switch without a default — returns undefined for unknown actions. Fix: exhaustive switch with never.
fetch() in the reducer — reducers must be pure. Fix: dispatch from effects, reducer only computes state.
dispatch is stable, but state-derived callbacks aren't. Fix: useCallback when passing derived handlers down.
Both re-render the same way. Fix: choose based on readability; use useMemo for perf.
Actions typed as { type: string; payload?: any }. Fix: discriminated union — { type: 'add'; by: number } | { type: 'reset' }.
Reducer inline in the component — hard to test. Fix: define it outside the component, export for tests.
❓ Frequently asked
What is the difference between useState and useReducer?
useState manages one value and exposes a setter. useReducer manages a state object via a reducer function and dispatches actions. useState is simpler; useReducer scales better for complex state. Both trigger re-renders. Choose based on complexity: 1-2 independent values → useState; 3+ related values or state transitions → useReducer.
When should I use useReducer?
Three signals. (1) The state has multiple related fields that transition together. (2) The next state depends on the previous state in non-trivial ways — undo, redo. (3) The state update logic is complex enough to test independently — reducers are pure functions.
Is useReducer faster than useState?
No. Both trigger re-renders the same way. The performance is nearly identical for most use cases. Choose useReducer for code clarity and testability, not for speed.
How does dispatch compare to setState?
setState takes a new value or updater function. dispatch takes an action object. The reducer function decides what the new state is based on the action. dispatch is stable — it never changes identity across renders.