Chapter 03
Modern Hooks Deep Dive
A deep look at the five always-needed React hooks, useTransition / useDeferredValue, useSyncExternalStore, custom hook patterns, and the anti-patterns the React Compiler will not save you from.
Published 2026-05-23
🎯 Chapter Goal — After this chapter you can use the five “always-needed” hooks correctly (and know how the compiler changes their role), reach for
useTransition/useDeferredValuefor responsiveness, bridge to external stores viauseSyncExternalStore, write custom hooks with the right shape, and recognise the anti-patterns the compiler won’t save you from.🧭 Prerequisites — Ch 2 (Compiler, refs-as-props,
use()).
🔹 3.1 The five always-needed hooks (and how the compiler changes their role)
useState
The most-used hook. Holds local state.
const [value, setValue] = useState<string>('');
Functional updates — prefer when the new value depends on the old:
setCount((c) => c + 1); // safe under batching, async closures, StrictMode
setCount(count + 1); // closure-stale risk
Lazy initialiser — for expensive default computation:
const [tree, setTree] = useState(() => buildTreeFromProps(props));
The function runs once at mount, not on every render.
Compiler impact: the compiler doesn’t change useState. State cells are state cells.
useEffect
Side effects. Runs after render commits and after the browser paints.
useEffect(() => {
const id = setInterval(tick, 1000);
return () => clearInterval(id);
}, []);
The cleanup rule: if your effect subscribes / opens / starts something, the cleanup function unsubscribes / closes / stops it. Memory leaks (Ch 35.3) are nearly always missing cleanups.
Dependency array:
[]— runs once on mount, cleanup on unmount.[x]— runs whenxchanges.- omitted — runs on every render. Almost always wrong.
Compiler impact: the compiler doesn’t generally rewrite effects (they’re side-effecting; their order matters). It does keep stable callback identities so effects don’t re-run unnecessarily.
useMemo
Memoise a computed value across renders.
const visible = useMemo(() => items.filter(matchesFilter), [items, filter]);
Compiler impact: the compiler eliminates ~80 % of useMemo calls. Most “for performance” useMemo wraps are now redundant. Ch 2 §2.1.
When to keep a manual useMemo:
- The compiler skipped this component (lint flagged Rules of React violation).
- You need referential stability for a dependency-array elsewhere — but the compiler usually handles that too.
useCallback
Memoise a function reference across renders.
const onSubmit = useCallback((data) => save(data), [save]);
Compiler impact: same as useMemo — mostly removable. Keep only when the compiler isn’t acting on the file.
useRef
Two distinct uses:
- DOM ref —
const ref = useRef<HTMLDivElement>(null); <div ref={ref} />. - Mutable container — any value that should persist across renders without triggering re-renders.
// timer ID kept across renders, no re-render trigger
const timerId = useRef<number | null>(null);
useEffect(() => {
timerId.current = window.setInterval(tick, 1000);
return () => { if (timerId.current) window.clearInterval(timerId.current); };
}, []);
⚠️ Reading or writing ref.current during render is a Rules of React violation and the compiler will skip the component. Reads/writes in event handlers and effects are fine.
🔹 3.2 useTransition and useDeferredValue
Both tell React “this update is non-urgent; keep the UI responsive.”
useTransition
Wrap a state update that you know is expensive:
const [isPending, startTransition] = useTransition();
const onFilterChange = (next: string) => {
setFilter(next); // urgent — input updates immediately
startTransition(() => setHeavyDerivation(compute(next))); // non-urgent
};
While the transition is running, the urgent state (the input) stays responsive. The non-urgent state can be interrupted if the user types again.
isPending lets you show a subtle “loading” indicator without blocking the input.
useDeferredValue
For props or derived values where you can’t reach the setState:
const ExpensiveList = ({ filter }: { filter: string }) => {
const deferredFilter = useDeferredValue(filter);
const filtered = useMemo(
() => items.filter((i) => i.name.includes(deferredFilter)),
[deferredFilter],
);
return <List items={filtered} />;
};
deferredFilter lags behind filter. The component renders with the old filter while a background render computes the new one. The user keeps typing without jank.
Picking between them:
- You own the
setState→useTransition. - You receive the value as a prop →
useDeferredValue.
🔹 3.3 useSyncExternalStore
The bridge from React to external state libraries (Zustand, Redux, Jotai, browser APIs).
const useWindowWidth = () => useSyncExternalStore(
(callback) => {
window.addEventListener('resize', callback);
return () => window.removeEventListener('resize', callback);
},
() => window.innerWidth,
() => 1024, // SSR fallback
);
Three arguments:
- subscribe — register a callback that fires when the store changes.
- getSnapshot — read the current value.
- getServerSnapshot — read the value during SSR (must be deterministic).
Why it matters: without useSyncExternalStore, external stores were prone to tearing under concurrent rendering — different components reading different values from the same store within a single render. This hook fixes it once and for all.
You mostly won’t write this hook directly — Zustand, Jotai, Redux all use it internally. But knowing it’s there explains why those libraries work safely under concurrent React.
🔹 3.4 Custom hook patterns
A custom hook is a function whose name starts with use and that may call other hooks.
Composition
const useInvoice = (id: string) => {
const { data, isLoading } = useQuery({ queryKey: ['invoice', id], queryFn: () => fetchInvoice(id) });
const isOverdue = data?.status === 'overdue';
return { invoice: data, isLoading, isOverdue };
};
Custom hooks compose lower-level hooks. They’re the right unit for “a piece of stateful logic I want to reuse.”
Return-shape conventions
- Tuple — when there are 2 values with no obvious names:
const [value, setValue] = useState(…). - Object — when there are >2 values or named meaning matters:
const { invoice, isLoading } = useInvoice(id).
Tuples destructure with custom names: const [c1, set1] = useCounter(). Objects don’t, but they accept additions without breaking call sites.
Testability
Custom hooks test with renderHook (Ch 36.5):
const { result, rerender } = renderHook(({ id }) => useInvoice(id), {
initialProps: { id: '1042' },
});
expect(result.current.isLoading).toBe(true);
Hooks that don’t require React context or providers are trivial to test. Ones that do need a wrapper (renderHook(..., { wrapper: AllProviders })).
Naming
- Start with
use. Linter enforces. - Describe the value, not the implementation:
useInvoice(id)notuseFetchInvoice(id). - Avoid
useGetX— verbs are implicit.
🔹 3.5 Anti-patterns the compiler will NOT save you from
The Compiler (Ch 2 §2.1) handles memoization. It does not fix:
1. Effects that fire too often
useEffect(() => {
loadData(filter);
}, [filter]); // ← fires on every keystroke
Fix: debounce, or move to TanStack Query with the right queryKey.
2. Conditional hook calls
if (cond) useState(0); // ← Rules of React violation
Fix: move the condition inside, or use use() (Ch 2 §2.3) for the conditional patterns it supports.
3. State that should be derived
const [items, setItems] = useState(allItems);
const [filtered, setFiltered] = useState(allItems);
useEffect(() => {
setFiltered(items.filter(matchesFilter));
}, [items, filter]);
Fix: derive inline. const filtered = items.filter(matchesFilter). The compiler memoises if it’s expensive.
4. Effects that read from state and write back
useEffect(() => {
setX(computeFromY(y)); // ← runs after render, triggers another render
}, [y]);
Fix: derive x from y in render, no effect needed.
5. State stored as a ref
const value = useRef(0);
return <div>{value.current}</div>; // ← won't re-render on change
Fix: useState. Refs don’t trigger renders by design.
6. Mutation of state objects
state.items.push(newItem);
setState(state); // ← React sees the same reference; doesn't re-render
Fix: setState({ ...state, items: [...state.items, newItem] }). Or use Immer.
7. useEffect for things that aren’t side effects
useEffect(() => {
document.title = `${name} · Acme`;
}, [name]);
Fix: in React 19.2, <title>{name} · Acme</title> (Ch 2 §2.4). Effects for things React has a native primitive for are wasted.
🪤 Common Pitfalls
- Reading
ref.currentduring render → Rules violation; compiler skips the file. - Empty
[]deps to silence the linter → stale closures (Ch 35.1). useDeferredValueon a value that’s already cheap → no benefit, dead complexity.- Writing custom hooks too eagerly — sometimes it’s just a function.
- Returning a new object from a custom hook every render → consumers re-render unnecessarily (compiler usually fixes this; check the badge).
✅ Recap
- Five always-needed hooks; compiler eliminates most manual memoization.
useTransition/useDeferredValuefor responsiveness under heavy work.useSyncExternalStoreis the bridge that makes external stores tear-free.- Custom hooks compose lower-level hooks; tuple or object return based on count.
- The compiler doesn’t save you from anti-patterns 3.5.1–3.5.7.
🔗 Further Reading
- https://react.dev/reference/react — official hook reference (React 19.2).
- Dan Abramov — “A Complete Guide to useEffect” (still the canonical write-up).
- Ch 2 (19.2 — Compiler, refs-as-props), Ch 13 (Zustand uses useSyncExternalStore internally), Ch 35 (the hook anti-pattern bugs in practice).
In the book — not on the site
Each topic has an 🧠 Under-the-hood subsection — the algorithm, the data structures, what React DevTools surfaces, debugging recipes. Plus a 🧪 hands-on lab per chapter with a starter repo. Reserved for the book.