βοΈ The useReducer Hook
You don't change a bank balance by reaching in and rewriting the number β you perform an action: deposit, withdraw, transfer. Each action follows the rules and produces a new, recorded balance. useReducer brings that same discipline to React state: components dispatch actions describing what happened, and one pure function decides how the state changes.
Week 5 · Monday: React Hooks Deep Dive · Lecture 2
π― Learning Objectives
By the end of this lesson, you will be able to:
- Explain the state β action β reducer β new state cycle
- Call
useReducer(reducer, initialState)and readstate/dispatch - Write a pure reducer that returns new state without mutation
- Choose between
useStateanduseReducerfor a given situation - Model async flows (loading / success / error) as reducer actions
- Combine
useReducerwith Context for app-wide state
Estimated Time: 65 minutes
Practice: Build a counter reducer, a todo reducer, and a small async data reducer.
In This Lesson
The Dispatch β State Cycle
Every useReducer app runs the same loop. The user does something, the component dispatches an action (a plain object describing the event), the reducer receives the current state plus that action and returns the next state, and React re-renders with it. The reducer is the single place where state transitions live β which makes the whole flow predictable and easy to test.
Read it clockwise: state and an action flow into the reducer, a new state flows out, the UI updates, the user acts, and the next action begins the cycle again. Nothing mutates state directly β every change is a fresh object returned by the reducer.
Anatomy of useReducer
const [state, dispatch] = useReducer(reducer, initialState);
// state β the current state value
// dispatch β a function you call with an action, e.g. dispatch({ type: 'INCREMENT' })
// reducer β (state, action) => newState (must be PURE)
// initialState β the starting state
| Term | What it is | Shape |
|---|---|---|
| action | A description of an event | { type: 'ADD', payload: β¦ } |
| dispatch | Sends an action to the reducer | dispatch(action) |
| reducer | Computes the next state | (state, action) => state |
| state | The current value React renders | Any object / array / value |
π‘ Why "reducer"?
The name comes from Array.prototype.reduce: you feed it an accumulator (the state) and an item (the action) and it returns the new accumulator. A React reducer is the same idea applied over time β each dispatched action reduces into the next state.
Your First Reducer
The classic counter, rewritten with actions. Notice the component never computes new state itself β it only describes what happened.
import { useReducer } from 'react';
const initialState = { count: 0 };
// Pure: same (state, action) always yields the same result, no side effects.
function reducer(state, action) {
switch (action.type) {
case 'INCREMENT':
return { count: state.count + 1 };
case 'DECREMENT':
return { count: state.count - 1 };
case 'RESET':
return { count: 0 };
default:
// Unknown action: return state unchanged.
return state;
}
}
function Counter() {
const [state, dispatch] = useReducer(reducer, initialState);
return (
<div>
<h2>Count: {state.count}</h2>
<button onClick={() => dispatch({ type: 'INCREMENT' })}>+</button>
<button onClick={() => dispatch({ type: 'DECREMENT' })}>β</button>
<button onClick={() => dispatch({ type: 'RESET' })}>Reset</button>
</div>
);
}
What happens on a click
click "+" β dispatch({ type: 'INCREMENT' })
β reducer({ count: 0 }, { type: 'INCREMENT' })
β returns { count: 1 }
β React re-renders β "Count: 1"
useState vs useReducer
Both manage state. Reach for useReducer when the logic gets complex enough that scattered setX calls become hard to follow.
Prefer useState when⦠| Prefer useReducer when⦠|
|---|---|
| State is a single simple value | State is a shape with several related fields |
| Updates are independent | The next state depends on the previous one |
| Few transitions | Many distinct transitions / event types |
| Logic lives happily in the component | You want transition logic testable in isolation |
Here's the same todo state both ways. With useState, related updates sprawl across many setters:
// useState β several setters, easy to let them drift out of sync
function TodosWithState() {
const [todos, setTodos] = useState([]);
const [filter, setFilter] = useState('all');
const [sortBy, setSortBy] = useState('date');
const [searchTerm, setSearchTerm] = useState('');
const addTodo = (text) =>
setTodos(prev => [...prev, { id: Date.now(), text, completed: false }]);
const toggleTodo = (id) =>
setTodos(prev =>
prev.map(t => (t.id === id ? { ...t, completed: !t.completed } : t))
);
// β¦plus delete, filter, sort, search setters
}
With useReducer, one state object and one reducer hold every transition in a single readable place:
const initialState = { todos: [], filter: 'all', sortBy: 'date', searchTerm: '' };
function todoReducer(state, action) {
switch (action.type) {
case 'ADD_TODO':
return {
...state,
todos: [
...state.todos,
{ id: Date.now(), text: action.payload, completed: false },
],
};
case 'TOGGLE_TODO':
return {
...state,
todos: state.todos.map(t =>
t.id === action.payload ? { ...t, completed: !t.completed } : t
),
};
case 'DELETE_TODO':
return {
...state,
todos: state.todos.filter(t => t.id !== action.payload),
};
case 'SET_FILTER':
return { ...state, filter: action.payload };
case 'SET_SORT':
return { ...state, sortBy: action.payload };
case 'SET_SEARCH':
return { ...state, searchTerm: action.payload };
default:
return state;
}
}
function TodosWithReducer() {
const [state, dispatch] = useReducer(todoReducer, initialState);
return (
<button onClick={() => dispatch({ type: 'ADD_TODO', payload: 'New todo' })}>
Add Todo
</button>
);
}
β The payoff
Every way the todo state can change is listed in one switch. Components shrink to "describe the event and dispatch it." You can unit-test todoReducer as a plain function β no React, no rendering required.
Practical Patterns
Action creators
Instead of hand-writing action objects everywhere, wrap them in small functions. Typos become impossible and the call sites read like sentences.
const todoActions = {
addTodo: (text) => ({ type: 'ADD_TODO', payload: text }),
toggleTodo: (id) => ({ type: 'TOGGLE_TODO', payload: id }),
deleteTodo: (id) => ({ type: 'DELETE_TODO', payload: id }),
setFilter: (f) => ({ type: 'SET_FILTER', payload: f }),
};
dispatch(todoActions.addTodo('Learn useReducer'));
Name action types as constants
A stray string like 'ADD_TOOD' silently falls through to default. Centralizing the strings turns those bugs into obvious reference errors.
const ActionTypes = {
ADD_TODO: 'ADD_TODO',
TOGGLE_TODO: 'TOGGLE_TODO',
DELETE_TODO: 'DELETE_TODO',
};
// In the reducer:
case ActionTypes.ADD_TODO: /* β¦ */
Derive, don't store
Just like with context, compute totals from state rather than adding another action to keep them in sync.
function ShoppingCart() {
const [state, dispatch] = useReducer(cartReducer, { items: [] });
// Derived on every render β always correct, never stale.
const totalItems = state.items.reduce((sum, i) => sum + i.quantity, 0);
const totalPrice = state.items.reduce(
(sum, i) => sum + i.price * i.quantity,
0
);
return (
<div>
<h2>Cart ({totalItems} items)</h2>
<h3>Total: ${totalPrice.toFixed(2)}</h3>
</div>
);
}
Async Flows as Actions
A data fetch has three natural states: started, succeeded, failed. Modeling each as an action keeps loading, data, and error from ever contradicting each other. Note: the await lives in the event handler, not in the reducer β reducers stay pure.
function asyncReducer(state, action) {
switch (action.type) {
case 'FETCH_START':
return { ...state, loading: true, error: null };
case 'FETCH_SUCCESS':
return { ...state, loading: false, data: action.payload };
case 'FETCH_ERROR':
return { ...state, loading: false, error: action.payload };
default:
return state;
}
}
function DataFetcher() {
const [state, dispatch] = useReducer(asyncReducer, {
data: null,
loading: false,
error: null,
});
const fetchData = async () => {
dispatch({ type: 'FETCH_START' });
try {
const res = await fetch('/api/data');
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json();
dispatch({ type: 'FETCH_SUCCESS', payload: data });
} catch (error) {
dispatch({ type: 'FETCH_ERROR', payload: error.message });
}
};
return (
<div>
{state.loading && <LoadingSpinner />}
{state.error && <ErrorMessage error={state.error} />}
{state.data && <DataDisplay data={state.data} />}
<button onClick={fetchData}>Fetch Data</button>
</div>
);
}
β οΈ Keep the reducer pure
Never put fetch, setTimeout, or logging inside a reducer. A reducer must be a pure function of (state, action). Perform side effects in the event handler or a useEffect, then dispatch the result.
Combining with Context
The power move from the previous lesson: put a reducer's state and dispatch into Context so the whole app can read state and dispatch actions. Splitting them into two contexts means components that only dispatch don't re-render when state changes.
const AppStateContext = createContext(null);
const AppDispatchContext = createContext(null);
function appReducer(state, action) {
switch (action.type) {
case 'SET_USER':
return { ...state, user: action.payload };
case 'SET_THEME':
return { ...state, theme: action.payload };
case 'ADD_NOTIFICATION':
return {
...state,
notifications: [...state.notifications, action.payload],
};
default:
return state;
}
}
function AppProvider({ children }) {
const [state, dispatch] = useReducer(appReducer, {
user: null,
theme: 'light',
notifications: [],
});
return (
<AppStateContext.Provider value={state}>
<AppDispatchContext.Provider value={dispatch}>
{children}
</AppDispatchContext.Provider>
</AppStateContext.Provider>
);
}
// Guarded custom hooks, one per context.
function useAppState() {
const ctx = useContext(AppStateContext);
if (ctx === null) throw new Error('useAppState needs an AppProvider');
return ctx;
}
function useAppDispatch() {
const ctx = useContext(AppDispatchContext);
if (ctx === null) throw new Error('useAppDispatch needs an AppProvider');
return ctx;
}
function UserProfile() {
const { user } = useAppState();
const dispatch = useAppDispatch();
return (
<div>
<h2>Welcome, {user?.name ?? 'guest'}!</h2>
<button onClick={() => dispatch({ type: 'SET_USER', payload: null })}>
Logout
</button>
</div>
);
}
π‘ dispatch is stable
React guarantees the dispatch function identity never changes across renders. That's why putting it in its own context is safe β the dispatch context value stays constant, so dispatch-only components never re-render from it.
Practice & Quiz
ποΈ Exercise 1: A game state reducer
Goal: Write gameReducer handling START_GAME, PAUSE_GAME, UPDATE_SCORE (adds action.payload), NEXT_LEVEL, and GAME_OVER.
const initialGameState = {
isPlaying: false, isPaused: false, score: 0, level: 1, lives: 3,
};
function gameReducer(state, action) {
// TODO
}
π‘ Hint
Each case returns { ...state, /* changed fields */ }. UPDATE_SCORE should add to the existing score: score: state.score + action.payload.
β Solution
function gameReducer(state, action) {
switch (action.type) {
case 'START_GAME':
return { ...state, isPlaying: true, isPaused: false };
case 'PAUSE_GAME':
return { ...state, isPaused: !state.isPaused };
case 'UPDATE_SCORE':
return { ...state, score: state.score + action.payload };
case 'NEXT_LEVEL':
return { ...state, level: state.level + 1 };
case 'GAME_OVER':
return { ...state, isPlaying: false, lives: 0 };
default:
return state;
}
}
ποΈ Exercise 2: Undo / redo
Goal: Build a reducer for a text editor supporting WRITE, UNDO, and REDO over a history of past and future states.
const initialState = { past: [], present: '', future: [] };
β Solution
function historyReducer(state, action) {
const { past, present, future } = state;
switch (action.type) {
case 'WRITE':
return { past: [...past, present], present: action.payload, future: [] };
case 'UNDO':
if (past.length === 0) return state;
return {
past: past.slice(0, -1),
present: past[past.length - 1],
future: [present, ...future],
};
case 'REDO':
if (future.length === 0) return state;
return {
past: [...past, present],
present: future[0],
future: future.slice(1),
};
default:
return state;
}
}
π― Quick Quiz
Question 1: What does a reducer function receive and return?
Question 2: Which is the correct, pure way to increment a count in a reducer?
Question 3: Where should the await fetch(...) for a data load go?
Best Practices & Pitfalls
β Do
- Keep reducers pure: return new state, no mutation, no side effects
- Always spread the previous state:
{ ...state, changed: β¦ } - Handle the
defaultcase by returningstateunchanged - Name action types with constants and consider action creators
- Split
stateanddispatchcontexts when going app-wide
β Don't
- Mutate state (
state.count++,state.items.push(...)) inside a reducer - Perform
fetch, timers, or logging inside the reducer - Reach for
useReducerwhen a singleuseStateis clearer - Forget the
defaultbranch β an unknown action would returnundefined
β οΈ The mutation trap
// β Mutates the existing object β React may skip the re-render
function badReducer(state, action) {
state.count += 1;
return state;
}
// β
Returns a new object β React sees a new reference and re-renders
function goodReducer(state, action) {
return { ...state, count: state.count + 1 };
}
React decides whether to re-render by comparing references. Return the same object and it may conclude nothing changed.
Summary
π Key Takeaways
- The cycle is state β action β reducer β new state β render
- Components dispatch actions; the reducer decides how state changes
- Reducers must be pure: new state out, no mutation, no side effects
- Prefer
useReducerfor complex, interdependent state;useStatefor simple values - Model async as START / SUCCESS / ERROR actions, and pair with Context for app-wide state
π Additional Resources
- react.dev β useReducer reference
- react.dev β Extracting State Logic into a Reducer
- react.dev β Scaling Up with Reducer and Context
π What's Next?
You've now met useState, useContext, and useReducer. The next lesson shows how to package any combination of them into your own reusable tools: Custom Hooks β extracting stateful logic like useLocalStorage and useFetch.
π Solid progress!
Predictable, testable state transitions are a hallmark of professional React. You've got them now.