modern-react-spa

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 / useDeferredValue for responsiveness, bridge to external stores via useSyncExternalStore, 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 when x changes.
  • 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:

  1. DOM refconst ref = useRef<HTMLDivElement>(null); <div ref={ref} />.
  2. 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 setStateuseTransition.
  • 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) not useFetchInvoice(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

  1. Reading ref.current during render → Rules violation; compiler skips the file.
  2. Empty [] deps to silence the linter → stale closures (Ch 35.1).
  3. useDeferredValue on a value that’s already cheap → no benefit, dead complexity.
  4. Writing custom hooks too eagerly — sometimes it’s just a function.
  5. 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 / useDeferredValue for responsiveness under heavy work.
  • useSyncExternalStore is 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.

Topics in this chapter (5)
  1. 3.1 The five always-needed hooks (and how the compiler changes their role)
  2. 3.2 useTransition and useDeferredValue
  3. 3.3 useSyncExternalStore
  4. 3.4 Custom hook patterns
  5. 3.5 Anti-patterns the compiler will NOT save you from