1. useReducer: Modeling Complex State Transitions
Consider a shopping cart: adding an item, removing one, and changing a quantity all
touch the same array, and each needs slightly different logic. Doing this with
several separate useState calls tends to scatter that logic across every
event handler that touches the cart. useReducer centralizes it into one
place: a single function that takes the current state and an "action," and returns
the next state.
interface CartItem {
id: number;
name: string;
quantity: number;
}
type CartAction =
| { type: 'add'; item: Omit<CartItem, 'quantity'> }
| { type: 'remove'; id: number }
| { type: 'setQuantity'; id: number; quantity: number };
function cartReducer(state: CartItem[], action: CartAction): CartItem[] {
switch (action.type) {
case 'add': {
const existing = state.find(item => item.id === action.item.id);
if (existing) {
return state.map(item =>
item.id === action.item.id
? { ...item, quantity: item.quantity + 1 }
: item
);
}
return [...state, { ...action.item, quantity: 1 }];
}
case 'remove':
return state.filter(item => item.id !== action.id);
case 'setQuantity':
return state.map(item =>
item.id === action.id ? { ...item, quantity: action.quantity } : item
);
}
}
import { useReducer } from 'react';
function Cart() {
const [items, dispatch] = useReducer(cartReducer, []);
return (
<div>
<button onClick={() => dispatch({ type: 'add', item: { id: 1, name: 'Keyboard' } })}>
Add Keyboard
</button>
{items.map(item => (
<div key={item.id}>
{item.name} × {item.quantity}
<button onClick={() => dispatch({ type: 'remove', id: item.id })}>Remove</button>
</div>
))}
</div>
);
}
useReducer(reducer, initialState) returns the current state and a
dispatch function. Every state transition goes through
dispatch(action), which calls cartReducer(currentState, action)
and re-renders with whatever it returns. Notice the component itself never computes
cart logic directly — it just describes what happened ("add", "remove") and
the reducer decides what that means for state.
2. Actions & the Reducer Pattern
This structure — a typed action describing an event, and a pure function
computing the next state from it — is the exact pattern behind Redux (Week 10), just
without a library. Two properties make it worth learning here first, by hand:
- The reducer is a pure function. Same state + same action always produces the same result, with no side effects — no API calls, no
Math.random(), no mutating the inputstatedirectly. That purity is what makes reducer logic easy to test in isolation, without rendering any component at all. - Actions describe intent, not implementation.
{'{ type: 'remove', id: 1 }'}reads like something that happened in the UI, not "splice index 2 out of the array." This separation is what lets the same event handler logic stay stable even if you later change exactly how removal is implemented internally.
useState or useReducer?
If a piece of state is a single, independent value (a boolean toggle, a text input), useState is simpler and correct. Reach for useReducer when several related values update together in response to the same set of events, or when the "next state" logic itself is complex enough to be worth testing on its own.
3. Your First Custom Hook
A custom hook is nothing magical — it's a regular function whose name starts with
use, that calls other hooks inside it. That naming convention is what
lets React's linter (and other hooks) verify the Rules of Hooks are being followed.
Its entire purpose is extracting logic you'd otherwise duplicate across components.
function ProfileForm() {
const [isOpen, setIsOpen] = useState(false);
const toggle = () => setIsOpen(prev => !prev);
// ...
}
function Sidebar() {
const [isOpen, setIsOpen] = useState(false);
const toggle = () => setIsOpen(prev => !prev);
// ...
}
import { useState, useCallback } from 'react';
function useToggle(initial = false) {
const [value, setValue] = useState(initial);
const toggle = useCallback(() => setValue(prev => !prev), []);
return [value, toggle] as const;
}
function ProfileForm() {
const [isOpen, toggleOpen] = useToggle();
// ...
}
function Sidebar() {
const [isOpen, toggleOpen] = useToggle();
// ...
}
A crucial thing to understand: useToggle doesn't share state between the
two components. Each call creates its own independent useState — you've
extracted and reused the logic, not a shared value. Sharing an actual value
across components is Week 10's job (Context and global state), not a custom hook's.
4. Custom Hooks That Wrap Effects
The most common real-world custom hooks wrap a useEffect (plus often
some state) behind a clean, reusable name — hiding setup/cleanup details a consuming
component shouldn't need to think about.
import { useState, useEffect } from 'react';
function useLocalStorage<T>(key: string, initialValue: T) {
const [value, setValue] = useState<T>(() => {
const stored = localStorage.getItem(key);
return stored ? JSON.parse(stored) : initialValue;
});
useEffect(() => {
localStorage.setItem(key, JSON.stringify(value));
}, [key, value]);
return [value, setValue] as const;
}
// Usage -- looks exactly like useState, but persists automatically
function Settings() {
const [theme, setTheme] = useLocalStorage('theme', 'light');
return <button onClick={() => setTheme(theme === 'light' ? 'dark' : 'light')}>{theme}</button>;
}
function useDebouncedValue<T>(value: T, delayMs: number): T {
const [debounced, setDebounced] = useState(value);
useEffect(() => {
const timeout = setTimeout(() => setDebounced(value), delayMs);
return () => clearTimeout(timeout); // cancel if value changes before delay elapses
}, [value, delayMs]);
return debounced;
}
// Usage -- delays reacting to fast typing until it pauses
function SearchBox() {
const [query, setQuery] = useState('');
const debouncedQuery = useDebouncedValue(query, 300);
// An effect depending on debouncedQuery only fires 300ms after typing stops
useEffect(() => {
if (debouncedQuery) console.log('searching for', debouncedQuery);
}, [debouncedQuery]);
return <input value={query} onChange={e => setQuery(e.target.value)} />;
}
Notice how much Week 2 knowledge is packed into these eight lines: state, an effect with a dependency array, and a cleanup function that cancels a stale timeout. This is exactly the pattern you'll reach for constantly once real network requests enter the picture in Week 8 — debouncing a search input before firing a request on every keystroke.
5. The Rules of Hooks
Hooks work by relying on being called in the exact same order on
every render — React matches each useState/useEffect call
to its stored data by call order, not by name. Break that order, and hooks silently
read the wrong stored data. Two rules keep that order stable:
- Only call hooks at the top level. Never inside a condition, loop, or nested function.
- Only call hooks from React function components or other custom hooks. Never from a regular function or a class method.
function Profile({ showBio }: { showBio: boolean }) {
if (showBio) {
const [bio, setBio] = useState(''); // conditional hook call -- illegal
}
// If showBio flips between renders, every hook AFTER this one
// shifts position and reads the wrong stored state.
}
function Profile({ showBio }: { showBio: boolean }) {
const [bio, setBio] = useState(''); // always called, unconditionally
if (showBio) {
return <p>{bio}</p>; // conditional RENDERING is fine -- just not conditional hooks
}
return null;
}
The fix is always the same shape: call the hook unconditionally at the top, and put
the conditional logic in what you do with its value afterward. React's ESLint
plugin (eslint-plugin-react-hooks, included by default in a Vite React
template) catches almost every violation of this automatically — treat its warnings
as errors, not suggestions.
6. Hands-on Exercise
Build a reducer-powered todo list, then extract a reusable hook from it
Combine this week's two ideas: centralized state transitions, and reusable logic extraction.
Part 1 — Reducer:
- Define a
Todotype ({'{ id, text, done }'}) and aTodoActionunion withadd,toggle,remove, andclearCompletedvariants. - Write
todoReducer(state, action)handling all four actions, and use it viauseReducerin aTodoAppcomponent with a text input, an add button, a list of toggleable items, and a "Clear completed" button.
Part 2 — Extract a custom hook:
- Move the
useReducercall and all fourdispatch(...)calls out ofTodoAppinto auseTodos()custom hook that returns{'{ todos, addTodo, toggleTodo, removeTodo, clearCompleted }'}— named functions instead of rawdispatchcalls. TodoAppshould now callconst { todos, addTodo, ... } = useTodos();and never referencedispatchor the reducer directly.- Persist the todos with
useLocalStorage(from Section 4) insideuseTodos, so the list survives a page refresh.
You can't directly combine useReducer and useLocalStorage as written this week — instead, initialize the reducer's state from localStorage (like useLocalStorage's lazy initializer) and add a useEffect inside useTodos that writes todos back to localStorage whenever it changes.
7. Knowledge Check
Four quick questions. Expand each to check your answer.
Q1
What two things does useReducer return, and what does dispatch actually do?
What two things does useReducer return, and what does dispatch actually do?
The current state and a dispatch function. Calling dispatch(action) runs your reducer with the current state and that action, and schedules a re-render with whatever the reducer returns — it never mutates state directly, it just triggers the same "compute next state" flow every action goes through.
Q2
Why must a reducer function be pure — no API calls, no Math.random(), no mutating its input?
Why must a reducer function be pure — no API calls, no Math.random(), no mutating its input?
React may call a reducer more than once per action in some cases (like Strict Mode's development double-invoking, used to surface exactly these kinds of bugs), and relies on being able to predict its output from its inputs. A side effect or non-deterministic result inside the reducer would run at unpredictable times or produce inconsistent state, and mutating the input directly breaks React's reference-equality change detection entirely.
Q3
When two components both call useToggle(), do they share the same boolean value?
When two components both call useToggle(), do they share the same boolean value?
No. A custom hook is just a function that calls other hooks — each call site gets its own completely independent useState internally. A custom hook reuses logic, not state; making two components share an actual value requires lifting that state up or using Context (Week 10), not a custom hook on its own.
Q4
Why does calling useState inside an if block break a component?
Why does calling useState inside an if block break a component?
React identifies each hook's stored data by the order hooks are called in, not by name or variable — it assumes call #3 this render is the same hook as call #3 last render. If a condition makes that hook call happen sometimes and not others, every hook after it shifts position between renders, and React ends up reading the wrong stored state for each one.