The Core Question
React gives you two hooks for managing state: useState and useReducer. They do the same fundamental thing โ store state and trigger re-renders when it changes. The difference is how you express state updates.
// useState: "set state to this new value"
setCount(count + 1);
setUser({ ...user, name: 'Bob' });
// useReducer: "something happened, figure out the new state"
dispatch({ type: 'INCREMENT' });
dispatch({ type: 'UPDATE_NAME', payload: 'Bob' });
useState describes the result. useReducer describes the event. This distinction becomes important as state logic grows more complex.
useState: Simple State
useState is the right choice for simple, independent state values.
function LoginForm() {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [showPassword, setShowPassword] = useState(false);
const [error, setError] = useState(null);
const handleSubmit = async (e) => {
e.preventDefault();
setError(null);
try {
await login(email, password);
} catch (err) {
setError(err.message);
}
};
return (
<form onSubmit={handleSubmit}>
{error && <p className="error">{error}</p>}
<input
type="email"
value={email}
onChange={e => setEmail(e.target.value)}
/>
<input
type={showPassword ? 'text' : 'password'}
value={password}
onChange={e => setPassword(e.target.value)}
/>
<label>
<input
type="checkbox"
checked={showPassword}
onChange={() => setShowPassword(s => !s)}
/>
Show password
</label>
<button type="submit">Login</button>
</form>
);
}
This works well because each state variable is independent. Changing email doesn't affect password. Toggling showPassword doesn't affect error. Each has a simple setter.
useReducer: Complex State
useReducer shines when state variables are interdependent, when multiple actions affect the same state, or when the next state depends on the previous state in complex ways.
Syntax
const [state, dispatch] = useReducer(reducer, initialState);
- reducer โ a pure function
(state, action) => newState - initialState โ the starting state value
- state โ current state
- dispatch โ function to send actions to the reducer
The Reducer Function
A reducer takes the current state and an action, then returns the new state. It must be a pure function โ no side effects, no mutations.
function reducer(state, action) {
switch (action.type) {
case 'INCREMENT':
return { ...state, count: state.count + 1 };
case 'DECREMENT':
return { ...state, count: state.count - 1 };
case 'RESET':
return { ...state, count: 0 };
case 'SET':
return { ...state, count: action.payload };
default:
throw new Error(`Unknown action: ${action.type}`);
}
}
function Counter() {
const [state, dispatch] = useReducer(reducer, { count: 0 });
return (
<div>
<p>Count: {state.count}</p>
<button onClick={() => dispatch({ type: 'INCREMENT' })}>+1</button>
<button onClick={() => dispatch({ type: 'DECREMENT' })}>-1</button>
<button onClick={() => dispatch({ type: 'RESET' })}>Reset</button>
<button onClick={() => dispatch({ type: 'SET', payload: 42 })}>Set to 42</button>
</div>
);
}
How dispatch works:
dispatch({ type: 'INCREMENT' })
|
v
React calls: reducer(currentState, { type: 'INCREMENT' })
|
v
reducer returns new state: { count: currentState.count + 1 }
|
v
React compares new state with old state
|
v
If different -> re-render with new state
If same -> bail out (no re-render)
Real-World Example: Multi-Step Form
This is where useReducer becomes clearly better than useState. The form has interdependent state, complex transitions, and multiple actions.
const STEPS = ['personal', 'address', 'payment', 'review'];
const initialState = {
step: 0,
data: {
firstName: '',
lastName: '',
email: '',
street: '',
city: '',
zip: '',
cardNumber: '',
expiry: '',
},
errors: {},
isSubmitting: false,
isComplete: false,
};
function formReducer(state, action) {
switch (action.type) {
case 'UPDATE_FIELD':
return {
...state,
data: { ...state.data, [action.field]: action.value },
errors: { ...state.errors, [action.field]: undefined }, // Clear field error
};
case 'NEXT_STEP': {
const errors = validateStep(state.step, state.data);
if (Object.keys(errors).length > 0) {
return { ...state, errors };
}
return {
...state,
step: Math.min(state.step + 1, STEPS.length - 1),
errors: {},
};
}
case 'PREV_STEP':
return {
...state,
step: Math.max(state.step - 1, 0),
errors: {},
};
case 'GO_TO_STEP':
return {
...state,
step: action.payload,
errors: {},
};
case 'SUBMIT_START':
return { ...state, isSubmitting: true, errors: {} };
case 'SUBMIT_SUCCESS':
return { ...state, isSubmitting: false, isComplete: true };
case 'SUBMIT_ERROR':
return { ...state, isSubmitting: false, errors: { submit: action.error } };
case 'RESET':
return initialState;
default:
throw new Error(`Unknown action: ${action.type}`);
}
}
function MultiStepForm() {
const [state, dispatch] = useReducer(formReducer, initialState);
const handleFieldChange = (field, value) => {
dispatch({ type: 'UPDATE_FIELD', field, value });
};
const handleSubmit = async () => {
dispatch({ type: 'SUBMIT_START' });
try {
await submitForm(state.data);
dispatch({ type: 'SUBMIT_SUCCESS' });
} catch (err) {
dispatch({ type: 'SUBMIT_ERROR', error: err.message });
}
};
if (state.isComplete) {
return (
<div>
<h2>Thank you!</h2>
<button onClick={() => dispatch({ type: 'RESET' })}>Start Over</button>
</div>
);
}
return (
<div>
<StepIndicator steps={STEPS} current={state.step} />
{state.step === 0 && (
<PersonalFields
data={state.data}
errors={state.errors}
onChange={handleFieldChange}
/>
)}
{state.step === 1 && (
<AddressFields
data={state.data}
errors={state.errors}
onChange={handleFieldChange}
/>
)}
{state.step === 2 && (
<PaymentFields
data={state.data}
errors={state.errors}
onChange={handleFieldChange}
/>
)}
{state.step === 3 && <ReviewStep data={state.data} />}
<div className="form-actions">
{state.step > 0 && (
<button onClick={() => dispatch({ type: 'PREV_STEP' })}>Back</button>
)}
{state.step < STEPS.length - 1 ? (
<button onClick={() => dispatch({ type: 'NEXT_STEP' })}>Next</button>
) : (
<button
onClick={handleSubmit}
disabled={state.isSubmitting}
>
{state.isSubmitting ? 'Submitting...' : 'Submit'}
</button>
)}
</div>
{state.errors.submit && <p className="error">{state.errors.submit}</p>}
</div>
);
}
Compare: doing this with useState would require 8+ state variables, and the NEXT_STEP action (which validates AND advances) would need to coordinate multiple setState calls.
Dispatching Actions
Action Conventions
Actions are plain objects with a type field. Additional data goes in payload (common convention).
// Simple action (no data)
dispatch({ type: 'INCREMENT' });
dispatch({ type: 'RESET' });
// Action with payload
dispatch({ type: 'SET_NAME', payload: 'Alice' });
dispatch({ type: 'ADD_TODO', payload: { id: 1, text: 'Learn React' } });
// Action with named fields (alternative)
dispatch({ type: 'UPDATE_FIELD', field: 'email', value: '[email protected]' });
Action Creators
For complex actions, use helper functions to create action objects consistently.
// Action creators
const actions = {
increment: () => ({ type: 'INCREMENT' }),
decrement: () => ({ type: 'DECREMENT' }),
set: (value) => ({ type: 'SET', payload: value }),
addTodo: (text) => ({ type: 'ADD_TODO', payload: { id: Date.now(), text, done: false } }),
toggleTodo: (id) => ({ type: 'TOGGLE_TODO', payload: id }),
removeTodo: (id) => ({ type: 'REMOVE_TODO', payload: id }),
};
// Usage
dispatch(actions.addTodo('Learn useReducer'));
dispatch(actions.toggleTodo(123));
Initial State Functions
Like useState, useReducer supports lazy initialization for expensive initial state computation.
// Eager init (runs every render, result ignored after first)
const [state, dispatch] = useReducer(reducer, createExpensiveInitialState());
// Lazy init (third argument โ init function)
function init(initialCount) {
// Only runs once. Can do expensive work here.
const saved = localStorage.getItem('counter');
return {
count: saved ? JSON.parse(saved) : initialCount,
history: [],
};
}
const [state, dispatch] = useReducer(reducer, 0, init);
// React calls init(0) on first render only
The third argument to useReducer is an initializer function. React calls init(secondArgument) to compute the initial state.
Combining useReducer + useContext
This is one of the most powerful patterns in React. It gives you a Redux-like architecture with zero dependencies.
import { createContext, useContext, useReducer } from 'react';
// Define state shape and actions
const initialState = {
todos: [],
filter: 'all',
};
function todoReducer(state, action) {
switch (action.type) {
case 'ADD':
return {
...state,
todos: [...state.todos, {
id: Date.now(),
text: action.payload,
done: false,
}],
};
case 'TOGGLE':
return {
...state,
todos: state.todos.map(t =>
t.id === action.payload ? { ...t, done: !t.done } : t
),
};
case 'DELETE':
return {
...state,
todos: state.todos.filter(t => t.id !== action.payload),
};
case 'SET_FILTER':
return { ...state, filter: action.payload };
case 'CLEAR_COMPLETED':
return { ...state, todos: state.todos.filter(t => !t.done) };
default:
throw new Error(`Unknown action: ${action.type}`);
}
}
// Create contexts
const TodoStateContext = createContext(null);
const TodoDispatchContext = createContext(null);
// Provider
function TodoProvider({ children }) {
const [state, dispatch] = useReducer(todoReducer, initialState);
return (
<TodoStateContext.Provider value={state}>
<TodoDispatchContext.Provider value={dispatch}>
{children}
</TodoDispatchContext.Provider>
</TodoStateContext.Provider>
);
}
// Hooks
function useTodos() {
const context = useContext(TodoStateContext);
if (!context) throw new Error('useTodos must be used within TodoProvider');
return context;
}
function useTodoDispatch() {
const context = useContext(TodoDispatchContext);
if (!context) throw new Error('useTodoDispatch must be used within TodoProvider');
return context;
}
// Components that consume the state
function TodoList() {
const { todos, filter } = useTodos();
const dispatch = useTodoDispatch();
const filtered = todos.filter(todo => {
if (filter === 'active') return !todo.done;
if (filter === 'completed') return todo.done;
return true;
});
return (
<ul>
{filtered.map(todo => (
<li key={todo.id}>
<input
type="checkbox"
checked={todo.done}
onChange={() => dispatch({ type: 'TOGGLE', payload: todo.id })}
/>
<span style={{ textDecoration: todo.done ? 'line-through' : 'none' }}>
{todo.text}
</span>
<button onClick={() => dispatch({ type: 'DELETE', payload: todo.id })}>
X
</button>
</li>
))}
</ul>
);
}
function AddTodo() {
const dispatch = useTodoDispatch();
const [text, setText] = useState('');
const handleSubmit = (e) => {
e.preventDefault();
if (text.trim()) {
dispatch({ type: 'ADD', payload: text.trim() });
setText('');
}
};
return (
<form onSubmit={handleSubmit}>
<input value={text} onChange={e => setText(e.target.value)} />
<button type="submit">Add</button>
</form>
);
}
function FilterBar() {
const { filter } = useTodos();
const dispatch = useTodoDispatch();
return (
<div>
{['all', 'active', 'completed'].map(f => (
<button
key={f}
onClick={() => dispatch({ type: 'SET_FILTER', payload: f })}
style={{ fontWeight: filter === f ? 'bold' : 'normal' }}
>
{f}
</button>
))}
</div>
);
}
// App
function App() {
return (
<TodoProvider>
<h1>Todos</h1>
<AddTodo />
<FilterBar />
<TodoList />
</TodoProvider>
);
}
Architecture:
TodoProvider
|-- useReducer(todoReducer, initialState)
|-- TodoStateContext.Provider (state)
|-- TodoDispatchContext.Provider (dispatch)
|
|-- AddTodo (uses dispatch only โ doesn't re-render on state changes)
|-- FilterBar (uses state.filter + dispatch)
|-- TodoList (uses state.todos + state.filter + dispatch)
Benefits:
- Centralized state logic in reducer
- Predictable state transitions
- Easy to test (reducer is a pure function)
- Components that only dispatch don't re-render on state changes
State Machines with useReducer
useReducer naturally models state machines โ systems where the current state determines which actions are valid.
const STATES = {
IDLE: 'idle',
LOADING: 'loading',
SUCCESS: 'success',
ERROR: 'error',
};
function fetchReducer(state, action) {
// State machine: valid transitions depend on current status
switch (state.status) {
case STATES.IDLE:
if (action.type === 'FETCH') {
return { status: STATES.LOADING, data: null, error: null };
}
return state;
case STATES.LOADING:
if (action.type === 'SUCCESS') {
return { status: STATES.SUCCESS, data: action.payload, error: null };
}
if (action.type === 'ERROR') {
return { status: STATES.ERROR, data: null, error: action.payload };
}
return state;
case STATES.SUCCESS:
if (action.type === 'FETCH') {
return { status: STATES.LOADING, data: state.data, error: null };
}
if (action.type === 'RESET') {
return { status: STATES.IDLE, data: null, error: null };
}
return state;
case STATES.ERROR:
if (action.type === 'FETCH') {
return { status: STATES.LOADING, data: null, error: null };
}
if (action.type === 'RESET') {
return { status: STATES.IDLE, data: null, error: null };
}
return state;
default:
return state;
}
}
State machine diagram:
IDLE --[FETCH]--> LOADING
|
+-----+-----+
| |
[SUCCESS] [ERROR]
| |
v v
SUCCESS ERROR
| |
[FETCH|RESET] [FETCH|RESET]
| |
FETCH->LOADING FETCH->LOADING
RESET->IDLE RESET->IDLE
Invalid transitions are ignored (return state unchanged):
IDLE + SUCCESS -> IDLE (no change)
LOADING + RESET -> LOADING (no change)
SUCCESS + ERROR -> SUCCESS (no change)
Using the State Machine
function DataFetcher({ url }) {
const [state, dispatch] = useReducer(fetchReducer, {
status: STATES.IDLE,
data: null,
error: null,
});
const fetchData = async () => {
dispatch({ type: 'FETCH' });
try {
const res = await fetch(url);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json();
dispatch({ type: 'SUCCESS', payload: data });
} catch (err) {
dispatch({ type: 'ERROR', payload: err.message });
}
};
return (
<div>
{state.status === STATES.IDLE && (
<button onClick={fetchData}>Load Data</button>
)}
{state.status === STATES.LOADING && <p>Loading...</p>}
{state.status === STATES.SUCCESS && (
<div>
<pre>{JSON.stringify(state.data, null, 2)}</pre>
<button onClick={fetchData}>Refresh</button>
<button onClick={() => dispatch({ type: 'RESET' })}>Reset</button>
</div>
)}
{state.status === STATES.ERROR && (
<div>
<p className="error">Error: {state.error}</p>
<button onClick={fetchData}>Retry</button>
<button onClick={() => dispatch({ type: 'RESET' })}>Reset</button>
</div>
)}
</div>
);
}
Side-by-Side Comparison
Let's implement the same feature with both hooks to see the difference clearly.
Counter with History (useState)
function CounterWithHistory() {
const [count, setCount] = useState(0);
const [history, setHistory] = useState([0]);
const [historyIndex, setHistoryIndex] = useState(0);
const increment = () => {
const newCount = count + 1;
const newHistory = [...history.slice(0, historyIndex + 1), newCount];
setCount(newCount);
setHistory(newHistory);
setHistoryIndex(newHistory.length - 1);
};
const decrement = () => {
const newCount = count - 1;
const newHistory = [...history.slice(0, historyIndex + 1), newCount];
setCount(newCount);
setHistory(newHistory);
setHistoryIndex(newHistory.length - 1);
};
const undo = () => {
if (historyIndex > 0) {
setHistoryIndex(historyIndex - 1);
setCount(history[historyIndex - 1]);
}
};
const redo = () => {
if (historyIndex < history.length - 1) {
setHistoryIndex(historyIndex + 1);
setCount(history[historyIndex + 1]);
}
};
// Problem: 3 setState calls per action, easy to get out of sync
// Problem: Logic is scattered across handlers
// Problem: Hard to add new actions consistently
return (
<div>
<p>{count}</p>
<button onClick={increment}>+1</button>
<button onClick={decrement}>-1</button>
<button onClick={undo} disabled={historyIndex === 0}>Undo</button>
<button onClick={redo} disabled={historyIndex === history.length - 1}>Redo</button>
</div>
);
}
Counter with History (useReducer)
function counterReducer(state, action) {
switch (action.type) {
case 'INCREMENT':
case 'DECREMENT': {
const delta = action.type === 'INCREMENT' ? 1 : -1;
const newCount = state.count + delta;
const newHistory = [...state.history.slice(0, state.index + 1), newCount];
return {
count: newCount,
history: newHistory,
index: newHistory.length - 1,
};
}
case 'UNDO': {
if (state.index <= 0) return state;
const newIndex = state.index - 1;
return { ...state, count: state.history[newIndex], index: newIndex };
}
case 'REDO': {
if (state.index >= state.history.length - 1) return state;
const newIndex = state.index + 1;
return { ...state, count: state.history[newIndex], index: newIndex };
}
default:
throw new Error(`Unknown action: ${action.type}`);
}
}
function CounterWithHistory() {
const [state, dispatch] = useReducer(counterReducer, {
count: 0,
history: [0],
index: 0,
});
// Clean: all state logic is in the reducer
// Easy to add new actions
// Impossible for state to get out of sync
return (
<div>
<p>{state.count}</p>
<button onClick={() => dispatch({ type: 'INCREMENT' })}>+1</button>
<button onClick={() => dispatch({ type: 'DECREMENT' })}>-1</button>
<button
onClick={() => dispatch({ type: 'UNDO' })}
disabled={state.index === 0}
>
Undo
</button>
<button
onClick={() => dispatch({ type: 'REDO' })}
disabled={state.index === state.history.length - 1}
>
Redo
</button>
</div>
);
}
Decision Guide
Use useState When
- State is a single primitive value (boolean, number, string)
- State updates are simple and direct (
setX(newValue)) - State variables are independent of each other
- The component is simple with few state transitions
- You're building a prototype and want minimal code
Use useReducer When
- State is an object with multiple sub-values
- State transitions are complex (next state depends on previous state + action type)
- Multiple actions modify the same state in different ways
- State variables are interdependent (updating one should update another)
- You want to centralize state logic for testing and readability
- You're building a state machine with defined transitions
- You want to separate "what happened" from "how state changes"
Quick Reference
| Criterion | useState | useReducer |
|---|---|---|
| State shape | Primitive or simple object | Complex object, nested, multiple fields |
| # of state transitions | 1-3 | 4+ |
| Update logic | Simple (direct set) | Complex (conditionals, validation) |
| Related state vars | No | Yes (should change together) |
| Testability | Test component | Test reducer as pure function |
| Debugging | Log state values | Log dispatched actions |
| Code organization | Logic in component | Logic in reducer (separate) |
Testing Reducers
One of the biggest advantages of useReducer is that reducers are pure functions โ trivially testable without rendering any components.
// counterReducer.test.js
describe('counterReducer', () => {
const initialState = { count: 0, history: [0], index: 0 };
test('INCREMENT increases count by 1', () => {
const result = counterReducer(initialState, { type: 'INCREMENT' });
expect(result.count).toBe(1);
expect(result.history).toEqual([0, 1]);
expect(result.index).toBe(1);
});
test('UNDO at index 0 returns same state', () => {
const result = counterReducer(initialState, { type: 'UNDO' });
expect(result).toBe(initialState); // Same reference โ no change
});
test('UNDO after INCREMENT returns to previous count', () => {
const afterIncrement = counterReducer(initialState, { type: 'INCREMENT' });
const afterUndo = counterReducer(afterIncrement, { type: 'UNDO' });
expect(afterUndo.count).toBe(0);
expect(afterUndo.index).toBe(0);
});
test('unknown action throws', () => {
expect(() => {
counterReducer(initialState, { type: 'UNKNOWN' });
}).toThrow('Unknown action: UNKNOWN');
});
});
No React rendering, no hooks, no providers โ just input/output testing.
Key Takeaways
- useState for simple state, useReducer for complex state โ there's no hard rule, but complexity is the deciding factor
- Reducers are pure functions โ
(state, action) => newState, no side effects, no mutations - Actions describe events, not direct state changes โ "what happened" vs "what the new state should be"
- Dispatch is stable โ it never changes between renders, so it's safe in dependency arrays and as a context value
- Lazy initialization โ use the third argument to
useReducerfor expensive initial state - useReducer + useContext โ a powerful combination for shared state without external libraries
- State machines โ useReducer naturally models state machines where valid transitions depend on current state
- Testability โ reducers can be unit tested as plain functions, without rendering components
- Centralized logic โ all state update rules live in one place instead of scattered across handlers
- Start with useState, refactor to useReducer when you notice: multiple setState calls per action, state variables that must stay in sync, or state logic that's hard to follow