Reducers, Refs, and Custom Hooks
Introduction
This is lesson 3 of chapter 6, aligned with course 6 of the Meta Front-End Developer pathway ("Advanced React", domain advanced-react). So far you have managed state with useState and synchronised with the outside world with useEffect. Three more tools complete the everyday toolbox:
useReducerkeeps complex state transitions in one predictable function.useRefstores a value that survives renders without causing a re-render, and gives access to DOM elements.- Custom hooks let you extract and reuse stateful logic between components.
The scenario is original: the Harbor Workshops booking cart grows rules (quantities, a seat limit, a promo code, undo of the last removal), the search box must receive focus when the page opens, and two screens need the same "load a list" logic. By the end of the lesson you will know which tool fits each situation and how to combine them.
Learning objectives
- Convert scattered
useStatecalls into a reducer with typed actions. - Write a pure reducer and dispatch actions from event handlers.
- Choose between
useStateanduseReducer. - Use
useReffor DOM access (focus, scroll, measurement) and for mutable values that must not trigger renders. - Explain why changing
ref.currentdoes not re-render and why it must not be read during rendering. - Extract logic into a custom hook, following the naming and rules of hooks.
- Return values from a custom hook in a convenient shape (object or array).
- Combine a reducer with context for shared state.
1. useReducer: many transitions, one place
useState is perfect for independent values. When the next state depends on a complicated rule, or when several fields change together, handlers scatter that logic around the component. A reducer concentrates it.
A reducer is a pure function (state, action) => newState:
const initialCart = { items: [], promo: null };
function cartReducer(state, action) {
switch (action.type) {
case 'added': {
const existing = state.items.find(i => i.id === action.id);
if (existing) {
return {
...state,
items: state.items.map(i =>
i.id === action.id ? { ...i, seats: Math.min(i.seats + 1, 6) } : i
),
};
}
return { ...state, items: [...state.items, { id: action.id, title: action.title, seats: 1 }] };
}
case 'removed':
return { ...state, items: state.items.filter(i => i.id !== action.id) };
case 'promo-applied':
return { ...state, promo: action.code };
case 'cleared':
return initialCart;
default:
throw new Error(`Unknown action: ${action.type}`);
}
}
Rules for a reducer:
- It must be pure: no fetching, no random numbers, no mutation. Return a new object or array; never
state.items.push(...). - It receives the current state and an action and returns the next state.
- Actions describe what happened (
'added'), not how to change the state ('set-items'). The reducer decides the consequences. - Throw (or return the same state) on unknown actions so typos are visible.
Using it in a component
import { useReducer } from 'react';
function Cart() {
const [cart, dispatch] = useReducer(cartReducer, initialCart);
return (
<>
<button onClick={() => dispatch({ type: 'added', id: 'w1', title: 'Sourdough basics' })}>
Add sourdough
</button>
<ul>
{cart.items.map(i => (
<li key={i.id}>
{i.title} × {i.seats}
<button onClick={() => dispatch({ type: 'removed', id: i.id })}>Remove</button>
</li>
))}
</ul>
</>
);
}
dispatch is stable between renders, so it can be passed to children or listed in dependency arrays without causing extra effect runs. React calls the reducer with the latest state, which avoids stale values that plague chained setState calls.
useState or useReducer?
| Criterion | useState |
useReducer |
|---|---|---|
| Amount of code for simple state | Less | More |
| Related fields that change together | Easy to forget one | One transition updates all |
| Complex rules for the next state | Spread across handlers | Centralised and testable |
| Debugging | Search every setX call |
Log or inspect actions |
| Testing | Test through the component | Test the reducer as a plain function |
A good rule: start with useState; move to useReducer when you notice many handlers editing the same state in similar ways, or when the logic deserves unit tests.
Worked example: undoable removal
Statement. Extend the cart so that removing an item can be undone once.
Step 1: decide what to store. Add lastRemoved: null to the state. Removing an item stores it there; undo puts it back.
Step 2: add two cases.
case 'removed': {
const item = state.items.find(i => i.id === action.id);
return {
...state,
items: state.items.filter(i => i.id !== action.id),
lastRemoved: item ?? null,
};
}
case 'undo-removed':
if (!state.lastRemoved) return state;
return {
...state,
items: [...state.items, state.lastRemoved],
lastRemoved: null,
};
Step 3: render the button conditionally.
{cart.lastRemoved && (
<button onClick={() => dispatch({ type: 'undo-removed' })}>
Undo removal of {cart.lastRemoved.title}
</button>
)}
Step 4: why the reducer is the better home. With separate useState calls, the removal handler must update items and lastRemoved as two separate calls, and any new code path that changes items could forget lastRemoved. In the reducer, one action updates both fields atomically. Returning state unchanged for an impossible undo avoids a needless re-render because React skips updates when the returned state is the same object.
Testing a reducer as a plain function
Because a reducer is pure, you can test it without rendering anything. This is one of the strongest arguments for useReducer.
import { cartReducer, initialCart } from './cartReducer';
test('adding the same workshop twice increases seats, capped at 6', () => {
let state = initialCart;
for (let i = 0; i < 8; i += 1) {
state = cartReducer(state, { type: 'added', id: 'w1', title: 'Sourdough basics' });
}
expect(state.items).toHaveLength(1);
expect(state.items[0].seats).toBe(6);
});
test('the initial state object is not mutated', () => {
const frozen = Object.freeze({ items: Object.freeze([]), promo: null });
const next = cartReducer(frozen, { type: 'added', id: 'w2', title: 'Wheel pottery' });
expect(next).not.toBe(frozen);
});
The first test checks a business rule (the six-seat limit); the second proves immutability, because mutating a frozen object throws in strict mode. Both run in milliseconds, with no DOM. Chapter 6 lesson 5 shows how to test the same cart through the user interface.
Worked example: choosing between state tools
Statement. A signup form has a name, an email, a "newsletter" checkbox, and a submit status (idle, sending, sent, error). A teammate proposes useReducer "because there are four pieces of state". Decide.
Step 1: list the transitions. The user types in a field (one value changes), toggles the checkbox (one value changes), submits (status becomes sending), and the server answers (status becomes sent or error).
Step 2: look for coupled changes. Only the status changes in response to the server, and no field depends on another. There is no rule such as "when X changes, Y must also change".
Step 3: decide. Two useState calls (one object for the fields, one string for the status) are clear and short. A reducer would add a switch statement without removing complexity.
Step 4: when the answer would change. If submitting also had to clear errors, lock all fields, store a retry count, and restore the previous values on failure, the coupled updates would justify a reducer. The number of variables is not the criterion; the complexity of their transitions is.
2. useRef: values outside rendering
A ref is an object { current: initialValue } that React keeps for the lifetime of the component. Changing ref.current does not trigger a re-render.
Use 1: access a DOM element
import { useRef, useEffect } from 'react';
function SearchBox() {
const inputRef = useRef(null);
useEffect(() => {
inputRef.current.focus();
}, []);
return (
<label>
Search workshops
<input ref={inputRef} type="search" />
</label>
);
}
React sets inputRef.current to the DOM node after the element is rendered, and back to null when it is removed. Typical uses: focusing, scrolling into view, measuring size, and controlling media playback. Prefer declarative props over refs when a prop exists; do not use refs to set text or classes that React manages.
Use 2: remember a mutable value across renders
function AutoSave({ draft }) {
const timerId = useRef(null);
useEffect(() => {
timerId.current = setTimeout(() => saveDraft(draft), 800);
return () => clearTimeout(timerId.current);
}, [draft]);
return null;
}
Good candidates: timer ids, previous values, and flags such as "has this analytics event already been sent". Values that affect what is displayed belong in state, not in refs.
useState versus useRef
useState |
useRef |
|
|---|---|---|
| Re-renders on change | Yes | No |
| Value read during render | Yes (it is the render input) | Avoid; reading current during render makes output unpredictable |
| Mutated directly | Never | Yes, via ref.current |
| Typical content | Anything shown on screen | DOM nodes, timer ids, previous values |
Passing a ref to your own component
In React 19, a function component can receive ref as an ordinary prop:
function TextField({ label, ref, ...props }) {
return (
<label>
{label}
<input ref={ref} {...props} />
</label>
);
}
// Parent
const emailRef = useRef(null);
<TextField label="Email" ref={emailRef} />
In React 18 and earlier (and in much existing code) you must wrap the component in forwardRef. Recognise both forms; forwardRef continues to work.
3. Custom hooks
A custom hook is a JavaScript function whose name starts with use and which calls other hooks. It extracts logic, not markup. Each component that calls the hook gets its own independent state; hooks share code, not data.
Rules of hooks
- Call hooks only at the top level of a component or custom hook: never inside loops, conditions, or nested functions.
- Call hooks only from React function components or other hooks.
These rules exist because React identifies each hook by its call order; changing the order between renders corrupts the association with state.
Extracting a data-loading hook
From the previous lesson, the fetch effect can be packaged:
function useWorkshops(category) {
const [workshops, setWorkshops] = useState([]);
const [status, setStatus] = useState('loading');
useEffect(() => {
const controller = new AbortController();
setStatus('loading');
fetch(`/api/workshops?category=${encodeURIComponent(category)}`, { signal: controller.signal })
.then(r => { if (!r.ok) throw new Error(r.status); return r.json(); })
.then(data => { setWorkshops(data); setStatus('success'); })
.catch(e => { if (e.name !== 'AbortError') setStatus('error'); });
return () => controller.abort();
}, [category]);
return { workshops, status };
}
function WorkshopList({ category }) {
const { workshops, status } = useWorkshops(category);
if (status === 'loading') return <p>Loading...</p>;
if (status === 'error') return <p role="alert">Something went wrong.</p>;
return <ul>{workshops.map(w => <li key={w.id}>{w.title}</li>)}</ul>;
}
The component now describes what to show; the hook knows how to get the data.
Another hook: persistent state
function useLocalStorage(key, initialValue) {
const [value, setValue] = useState(() => {
try {
const stored = localStorage.getItem(key);
return stored !== null ? JSON.parse(stored) : initialValue;
} catch {
return initialValue;
}
});
useEffect(() => {
try {
localStorage.setItem(key, JSON.stringify(value));
} catch {
/* storage may be full or blocked */
}
}, [key, value]);
return [value, setValue];
}
const [theme, setTheme] = useLocalStorage('theme', 'light');
Three details: the initial state uses lazy initialisation (a function passed to useState, run once), storage access is wrapped in try/catch because browsers can block it, and the hook returns an array so callers can name the pair freely, exactly like useState.
Return shape
| Return | When |
|---|---|
Array [value, setter] |
Two or three values with obvious roles; callers rename them |
Object { workshops, status } |
Several values; callers pick what they need and order does not matter |
Hooks and context together
A reducer plus context gives you shared, structured state:
const CartContext = createContext(null);
export function CartProvider({ children }) {
const [cart, dispatch] = useReducer(cartReducer, initialCart);
const value = useMemo(() => ({ cart, dispatch }), [cart]);
return <CartContext value={value}>{children}</CartContext>;
}
export function useCart() {
const ctx = useContext(CartContext);
if (!ctx) throw new Error('useCart must be used inside <CartProvider>');
return ctx;
}
Any component can now call const { cart, dispatch } = useCart(). This is the pattern used in many Meta course exercises.
Common pitfalls
- Mutating state inside the reducer (
state.items.push). React may skip the update or show stale UI. - Side effects in a reducer (fetch,
localStorage,Math.random). Reducers must be pure; in Strict Mode they are called twice. - Reading or writing
ref.currentduring rendering to display data. Use state. - Expecting a ref change to update the screen.
- Calling a hook conditionally (
if (open) useState(...)). - Naming a hook without the
useprefix, which prevents lint rules from checking it. - Thinking a custom hook shares state between components. Two components calling
useLocalStorage('theme')have separate states; share by lifting state or context. - Forgetting
defaultor athrowin the reducer, which silently returnsundefinedand wipes the state. - Dispatching functions or events as actions instead of plain serialisable objects.
Exam tips
- "Pure function that receives the current state and an action" is the definition of a reducer.
- The second item returned by
useReducerisdispatch; it does not accept a state value, it accepts an action. - If a question asks how to focus an input on load, expect
useRefanduseEffect. - If a question says a value must persist but changing it should not repaint, choose
useRef. - A function named
useSomethingthat is not called at the top level is a rules-of-hooks violation. - Custom hooks reuse logic, not state.
Mini case study: the shared counter that is not shared
Situation. A developer writes useSeatCount() that stores a number with useState. The header shows the count from useSeatCount() and the cart page calls useSeatCount() too. When the user adds a seat in the cart page, the header number stays at zero. The developer concludes that custom hooks "do not work".
Analysis. Every call to a hook creates a separate state slot belonging to the component that calls it. The header and the cart each own a different useState. The hook shared the code, not the data.
Solution. Keep the state in one place and share it:
- Move the state into a provider using
useReducer(oruseState). - Expose it through context.
- Make
useSeatCount()read the context:const { cart } = useCart(); return cart.items.reduce((n, i) => n + i.seats, 0);.
Now the hook is a convenient way to derive the number, and both components read the same source of truth.
Why other fixes are worse. Using a module-level variable would update the value but not re-render components. Copying the count into both components with useEffect creates two sources that can disagree.
Key terms
- Reducer: a pure function that computes the next state from the current state and an action.
- Action: a plain object describing what happened, usually with a
type. dispatch: the function returned byuseReducerthat sends an action.- Ref: a mutable container
{ current }that persists across renders without causing renders. - Lazy initialisation: passing a function to
useStateso the initial value is computed once. - Custom hook: a
use-prefixed function that reuses stateful logic. - Rules of hooks: call hooks at the top level, only from components or hooks.
forwardRef: the pre-19 wrapper for passing refs to function components.- Pure function: a function whose result depends only on its inputs and has no side effects.
Summary checklist
- I can write a pure reducer with descriptive action types.
- I use
dispatchwith plain action objects. - I choose
useReducerwhen transitions are complex or need unit tests. - I use
useReffor DOM access and non-visual mutable values. - I never read
ref.currentto render output. - I extract logic into
use-prefixed custom hooks and follow the rules of hooks. - I understand that each hook call has its own state.
- I can combine a reducer and context into a provider with a guarded hook.
Official sources
- Meta Front-End Developer Professional Certificate (opens in a new tab)
- Course 6: Advanced React (opens in a new tab)
- React: Extracting State Logic into a Reducer (opens in a new tab)
- React: Referencing Values with Refs (opens in a new tab)
- React: Reusing Logic with Custom Hooks (opens in a new tab)
- React: Rules of Hooks (opens in a new tab)
- React 19 upgrade guide (opens in a new tab)
