Skip to content

Every Core React Hook, Visualized — An Interactive Walkthrough

Posted on:April 26, 2026 at 10:00 AM

Most React tutorials hand you the hook signatures and walk away. That’s enough to ship a counter, not enough to debug stale closures, ghost re-renders, or useEffect cleanups firing in the wrong order. The fix isn’t more API surface — it’s seeing what React is actually doing between your setState call and your DOM update.

This post is the visual version of that. Six interactive scenes, one per hook family, each with a code panel, a live preview, and an internals panel that animates as you step through. A short prediction quiz closes it out.

Table of contents

Open Table of contents

The mental model in one paragraph

Every component is a fiber. Every fiber owns a small linked list of hook slots, in the order the hooks were called on the first render. When you call useState, React reads slot N and returns whatever’s there. When you call setState, React appends an update to that slot’s pending queue and marks the fiber dirty. The render phase walks the slots again, drains their queues, and produces fresh JSX. The commit phase writes the diff to the DOM. Effects run after the browser paints. Everything else is a variation on this loop — slot, queue, render, commit, paint, effect.

Hold that picture in mind. The visualizers below show every step of it.

The fiber, slots, and renders — the long version click to expand

A fiber is React’s internal record of a component instance. Think of it as a JavaScript object that holds everything React needs to remember between renders: the current props, the type of component, pointers to parent/sibling/child fibers, and — crucially — a linked list of hook records. There is one fiber per mounted component instance, and the entire tree of fibers mirrors the tree of components in your app.

When you call useState, useEffect, or any hook, React looks at the currently-rendering fiber and walks down its hook list to slot N (where N = “the Nth hook called this render”). The hook record at slot N stores the hook’s state — for useState, that’s the value and its update queue; for useEffect, the deps array and the cleanup function; for useMemo, the cached result and the deps used to produce it.

┌──────────────────── Fiber: <Counter /> ────────────────────┐ │ type: function Counter() │ │ props: { initial: 0 } │ │ state: ── linked list of hook records ──┐ │ │ ▼ │ │ ┌─ slot 0 ─┐ ┌─ slot 1 ─┐ ┌─ slot 2 ─┐ ┌─ slot 3 ─┐ │ │ │ useState │→ │ useState │→ │ useEffect│→ │ useMemo │ │ │ │ value: 7 │ │ value:'' │ │ deps: [7]│ │ cache:42 │ │ │ │ pending: │ │ pending: │ │ cleanup: │ │ deps:[7] │ │ │ │ [→8] │ │ [] │ │ () =>… │ │ │ │ │ └──────────┘ └──────────┘ └──────────┘ └──────────┘ │ │ │ │ child → Fiber: <button /> │ │ sibling → null │ └──────────────────────────────────────────────────────────────┘

A render in React is split into two phases:

  1. Render phase (pure) — React calls your component function. It walks the hook list in source order, returning whatever’s in each slot. If setState was called previously, the slot’s pending queue is drained and a new value is computed. Your component returns JSX (a description of what should be on screen). React diffs this against the previous tree to produce a list of mutations. Nothing has touched the DOM yet — this phase can be paused, resumed, or thrown away (it’s how concurrent rendering works).

  2. Commit phase (synchronous, side-effecting) — React applies the mutations to the real DOM and runs useLayoutEffect callbacks. This phase is uninterruptible. The browser then paints. After paint, React fires useEffect callbacks in a separate, asynchronous passive-effects flush. (The post lumps “render → commit → paint → effects” into one mental pipeline, but strictly: only the DOM mutate + layout effects bit is “the commit phase”.)

This separation is the entire conceptual win of React: you write a pure function that says “given this state, the UI should look like this”, and React figures out the minimum diff to reach that state from whatever was there before. Hooks are the API by which a stateless function gains access to per-instance state — without you ever having to write class extends Component or wire up this.state.

The “Rules of Hooks” — call them at the top level, in the same order, every render — exist because React identifies hooks by slot index, not name. If you wrap a hook in an if, the slot indices shift on whichever render takes the other branch, and React reads the wrong values from the wrong slots. Catastrophic and silent. The eslint plugin react-hooks/rules-of-hooks catches this statically.

Reading further: the official React docs page State as a Snapshot and Render and Commit cover this from a different angle. Dan Abramov’s old React as a UI Runtime goes deeper into the runtime model.

Scene 1 · State — useState & useReducer

Both hooks share the same machinery: a slot per call, an update queue, a dirty flag, a re-render. The only difference is whether the slot stores a value (useState) or the result of feeding actions through a reducer (useReducer).

State hooks — useState & useReducer
Hook slots live on the fiber, indexed by call order. Watch them fill, queue, and drain.
useState returns a value and a setter. Calling the setter schedules a re-render — the slot is updated, the function runs again, and React commits the new tree.
React lifecycle phasenothing to do
idle
event
schedule
render
commit
Phases not visited in a step weren't triggered — React only enters a phase when there's work for it. A ref mutation skips schedule/render/commit (no re-render). Mount skips event/schedule (nothing triggered it). Hover a pill for its meaning.
Code
1function Counter() {
2 const [n, setN] = useState(0);
3 return (
4 <button onClick={() => setN(n + 1)}>
5 count: {n}
6 </button>
7 );
8}
Live preview (what the user sees)
(not yet rendered)
renders0
👆 = simulated user click
Fiber internals (React's view)
fiber · (unmounted)
(no hooks yet)
Update queue (across all slots)
empty
Virtual DOM ↔ Real DOMin sync
VDOMjust produced by render()
(empty)
commit
diff & patch
Real DOMwhat the browser shows
(empty)
Committed. React applied the diff to the real DOM. The browser paints what the user actually sees.
Step 0 / 21Pick a scenario and press ▶ Play to walk through it step by step.
Speed:

A few rules these scenarios make obvious:

Batching, functional updaters, and the useState-vs-useReducer decision click to expand

Why batching is the default (and what that means)

Before React 18, batching only happened inside React-managed event handlers. State updates inside setTimeout, Promise.then, native event listeners, or async functions each triggered their own render — death by a thousand reconciliations. React 18 introduced “automatic batching”: any number of setState calls within the same JavaScript task are batched into a single render, regardless of where they originate.

── one event handler ───────────────────────────────────── onClick → setA(1) ┐ setB(2) │ ← 3 calls, ONE render setC(3) ┘ (a, b, c all updated atomically) ── async, pre-React-18 ─────────────────────────────────── setTimeout(() => { setA(1) → render setB(2) → render ← THREE renders setC(3) → render }, 0) ── async, React-18+ ────────────────────────────────────── setTimeout(() => { setA(1) ┐ setB(2) │ ← still ONE render setC(3) ┘ }, 0)

Functional updater: the closure trap, solved

When you write setN(n + 1), the value n is captured from the render that produced this closure. If you fire three setters in a row, all three see the same captured n (say 0) — and so all three compute 0 + 1, not 0 + 1 then 1 + 1 then 2 + 1. The result: only +1.

The functional form setN(prev => prev + 1) instead receives the latest pending value from the slot’s queue. Three increments now produce +3. Use the functional form whenever the new value depends on the old one — it’s the ergonomic equivalent of an atomic compare-and-swap.

// Bug: captures n=0 three times
setN(n + 1);
setN(n + 1); // still computing 0 + 1
setN(n + 1); // still computing 0 + 1
// Fix: each sees the latest pending
setN(prev => prev + 1); // 0 → 1
setN(prev => prev + 1); // 1 → 2
setN(prev => prev + 1); // 2 → 3

useState vs useReducer — the decision matrix

SituationPick
One scalar value, simple updates (counter, toggle)useState
2-3 related fields that update together (form input + isValid + error)useState × N — until it gets noisy, then useReducer
Complex state machine with many transitionsuseReducer — actions become a self-documenting API
Update logic is testable / shared (e.g., shopping cart math)useReducer — the reducer is a pure function, easy to unit-test
Next state depends on previous state in non-obvious waysuseReducer — co-locates the rule, not the call sites
You’re about to wire the same state into several event handlersuseReducerdispatch({ type }) is one line everywhere

Lazy initial state

useState accepts a function for expensive initialisation: useState(() => computeInitial()) — the function runs once on mount, never again. Without the function form, computeInitial() is called every render and its return value thrown away. Same trick for useReducer(reducer, initialArg, init) — the optional third arg.

Pitfall: beginners often write useState(computeInitial()) thinking it’s the same. It isn’t — the expensive call runs every render. The function form is mandatory for any non-trivial initialiser.

Identity rules React relies on

State updates are committed only if Object.is(oldValue, newValue) === false. So setItems(items) (passing the same reference) is a no-op — React skips the render. This is why mutating an array in place and calling setItems(items) doesn’t update the UI — the reference didn’t change. Always create a new array ([...items, x]) or object ({ ...obj, k: v }).

Scene 2 · Effects — useEffect (and friends)

Every render walks the same five-phase pipeline: render the virtual tree, mutate the DOM, fire useLayoutEffect synchronously, let the browser paint, then fire useEffect. Cleanup of the previous tick runs before the new tick’s setup — that’s what keeps subscriptions from leaking.

Effect timeline — useEffect & useLayoutEffect
Render → DOM mutate → layout effect → paint → effect. Every render walks this lane in order.
An effect with an empty `[]` dependency array — setup runs once after the first paint, cleanup runs once on unmount. Notice that paint happens BEFORE the effect — the user sees the new pixels first, the side-effect runs second.
React lifecycle phasenothing to do
idle
render
commit
paint
effects
Phases not visited in a step weren't triggered — React only enters a phase when there's work for it. A ref mutation skips schedule/render/commit (no re-render). Mount skips event/schedule (nothing triggered it). Hover a pill for its meaning.
Code
1function Greeter() {
2 useEffect(() => {
3 console.log("setup");
4 return () => console.log("cleanup");
5 }, []); // [] → mount once, unmount once
6 return <h1>hi</h1>;
7}
Live preview & console
(not rendered)
Console
(empty)
Render timeline (top → bottom = time)
render()
DOM mutate
useLayoutEffect
🖼 paint
useEffect
(no renders yet)
setup cleanup runs in next tick's effect column
Step 0 / 20Pick a scenario and press ▶ Play.
Speed:

Three things worth burning in:

useInsertionEffect is a niche extra step that runs synchronously before DOM mutations during commit — only CSS-in-JS libraries should ever reach for it. The timeline above doesn’t depict it as a separate column to keep the picture clean.

Strict Mode, double-invocation, and effect correctness click to expand

Why your effects fire twice in dev

In React 18+ with <StrictMode>, every effect runs setup → cleanup → setup again on mount. That’s not a bug — it’s React deliberately stress-testing your code. The contract is: your effect must be safe to run twice. If running setup-cleanup-setup leaks a subscription, opens two sockets, or fires two analytics events, your effect has a bug that would also bite you in production the moment React decides to remount the component (which it now reserves the right to do for offscreen rendering, suspense recovery, fast-refresh, and future features).

Production mount Strict-Mode dev mount ──────────────── ───────────────────── render render paint paint setup ✓ setup ✓ cleanup ✓ ← simulates remount setup ✓ ← real run …unmount… …unmount… cleanup ✓ cleanup ✓

The fix is always the same: make cleanup idempotent and complete. If setup adds a listener, cleanup removes it. If setup starts a timer, cleanup clears it. If setup makes an HTTP request, cleanup aborts it (modern fetch supports AbortSignal). Once your effect survives setup-cleanup-setup, it survives anything React throws at it.

The five most common cleanup bugs

// 1) Forgot to clean up an interval
useEffect(() => {
const id = setInterval(tick, 1000);
// BUG: no cleanup → multiple intervals stack up
}, []);
// Fix: return () => clearInterval(id)
// 2) Forgot deps array → effect runs every render
useEffect(() => {
fetchData();
});
// Fix: useEffect(() => { fetchData(); }, [/* relevant deps */])
// (note the braces — the body must NOT return the Promise from fetchData,
// or React will warn that the cleanup return value isn't a function.)
// 3) Async function as the effect body — wrong shape
useEffect(async () => {
const data = await fetch(url);
}, [url]);
// async fn returns a Promise. useEffect expects undefined or a cleanup fn —
// React warns and your "cleanup" silently never runs.
// Fix: declare an inner async function and call it
useEffect(() => {
let cancelled = false;
(async () => {
const data = await fetch(url);
if (!cancelled) setData(data);
})();
return () => { cancelled = true; };
}, [url]);
// 4) Effect that mutates state on every render → infinite loop
useEffect(() => {
setCount(count + 1);
}); // re-renders, runs again, re-renders…
// Fix: don't useEffect for derivation. Compute during render, or memoize
// the value with useMemo. See "When NOT to use useEffect" below.
// 5) Stale closure in an event listener
useEffect(() => {
const handler = () => console.log(count); // captures stale count
window.addEventListener("scroll", handler);
return () => window.removeEventListener("scroll", handler);
}, []); // ← deps wrong; this captures count=0 forever
// Fix: include count in deps, or read from a ref

When NOT to use useEffect

The single biggest improvement to most React codebases is deleting effects. The React team’s guidance in You Might Not Need an Effect is worth reading carefully. Common bad patterns:

You wroteWhat you should do instead
useEffect(() => setFullName(first + " " + last), [first, last])Compute during render: const fullName = first + " " + last
useEffect(() => { if (search) setResults(filter(items, search)) }, [items, search])const results = useMemo(() => filter(items, search), [items, search])
useEffect(() => { setUser(null) }, [route]) to “reset state on route change”Use the key prop on the component — React unmounts/remounts
useEffect(() => fetchData(), []) for the initial load in modern ReactPrefer the framework’s data-loading primitive (Next.js loaders, React Query, RSC). Effects for fetching are a minefield: race conditions, no caching, no error boundaries.

The mental rule: effects exist to synchronise with external systems (DOM measurements, browser APIs, network, third-party libs). They are NOT a place to derive state from props or run logic that “needs to happen after some action”.

useLayoutEffect vs useEffect — the deciding question

Ask: if the user sees the result of the effect for one frame before it runs, is that bad?

  • Yes, it would flickeruseLayoutEffect (synchronous, blocks paint, pays the perf cost).
  • No, it’s invisibleuseEffect (async, no perf cost).

Tooltip positioning, scroll restoration on navigation, focus trapping in a modal — all are useLayoutEffect. Subscriptions, analytics, debounced saves, network requests — all are useEffect.

Scene 3 · Context — useContext

Context is React’s broadcast system. A provider value change fans out to every consumer in its subtree. The provider doesn’t know who the consumers are; the consumers register their interest via useContext, and React walks the tree to find them.

Context — useContext propagation
A provider value change ripples to every consumer in its subtree. memo can NOT stop it.
When the provider's value changes, every consumer re-renders. Non-consumers in between are skipped (their props didn't change and the children prop is the same reference). React.memo does NOT block context updates — Article re-renders even though Main is memo'd.
React lifecycle phasenothing to do
idle
event
schedule
render
commit
Phases not visited in a step weren't triggered — React only enters a phase when there's work for it. A ref mutation skips schedule/render/commit (no re-render). Mount skips event/schedule (nothing triggered it). Hover a pill for its meaning.
Code
1const Theme = createContext("light");
2 
3function ThemeProvider({ children }) {
4 const [theme, setTheme] = useState("light");
5 return (
6 <Theme.Provider value={theme}>
7 <button onClick={() => setTheme("dark")}>flip</button>
8 {children} {/* same ref each render */}
9 </Theme.Provider>
10 );
11}
12 
13function App() {
14 return (
15 <ThemeProvider>
16 <Header /> {/* consumer */}
17 <Layout>
18 <Sidebar /> {/* consumer */}
19 <Main> {/* memo, no consumer */}
20 <Article /> {/* consumer */}
21 <Footer /> {/* nothing */}
22 </Main>
23 </Layout>
24 </ThemeProvider>
25 );
26}
Live preview (themed)
Header — theme: light
· Sidebar
Article body — themed text.
Footer (no theme)
Component tree (ripple shows propagation)
<App>×0
<ThemeProvider>×0
<Header>ctx×0
<Layout>×0
<Sidebar>ctx×0
<Main memo>memo×0
<Article>ctx×0
<Footer>×0
consumer memo rippled & re-rendered
Step 0 / 12Pick a scenario and press ▶ Play.
Speed:

The performance gotcha is in scenario 1: React.memo does not block context updates. A memo’d intermediate component can shield itself from props-driven re-renders, but a useContext consumer beneath it will still re-render when the context changes. If you’ve ever wrapped a component in memo and watched it re-render anyway, this is usually why.

The remedy isn’t to fight memo — it’s to split the context. Put theme, user, and cart in separate contexts so a setUser doesn’t drag every theme consumer along for the ride.

Context performance — why memo can't help, and what actually does click to expand

The propagation algorithm, in one sentence

When a provider’s value changes, React walks down the tree and bails out of components that don’t consume the context — but never bails out at a React.memo boundary if anything inside still consumes it. Memo is checked against props; context is checked against subscriptions. They’re orthogonal.

<ThemeProvider value={theme}> ← value changes │ ┌───────────┴────────────┐ ▼ ▼ <Header> <Layout> ← no context: skipped useContext (props unchanged) ▼ │ re-renders ✓ ├── <Sidebar> useContext → re-renders ✓ │ └── <Main memo> ← memo on props │ ← but Article inside… ▼ <Article> useContext → re-renders ✓ (memo did NOT shield it)

The mental model: context is a broadcast channel, not a prop. Subscribers tune in directly to the provider — the tree shape between them is irrelevant.

Three fixes for “everything re-renders”

1) Split the context. This is the cheapest, most effective fix. Don’t put { user, theme, cart, settings } in one mega-context. Make UserContext, ThemeContext, CartContext, SettingsContext, and consumers only re-render when their slice changes.

// Bad: one context, every change cascades
<AppContext.Provider value={{ user, theme, cart }}>
// Good: parallel providers, surgical re-renders
<UserContext.Provider value={user}>
<ThemeContext.Provider value={theme}>
<CartContext.Provider value={cart}>
{children}
</CartContext.Provider>
</ThemeContext.Provider>
</UserContext.Provider>

2) Memoise the value. A new object literal in the provider’s value prop = new reference every render = every consumer re-renders even when the underlying data didn’t change.

// Bad: new object every render of the parent
<MyContext.Provider value={{ user, login, logout }}>
// Good: stable object
const value = useMemo(
() => ({ user, login, logout }),
[user, login, logout]
);
<MyContext.Provider value={value}>
// (Note: if `login` / `logout` are inline arrows in the parent,
// they're new each render and this useMemo never hits.
// Wrap them in useCallback first.)

3) Selector pattern (advanced). When you can’t split the context (e.g., it’s a giant store from a third-party library), reach for a selector hook. Libraries like use-context-selector let consumers subscribe to a slice of the context value and only re-render when that slice changes. This is what Redux’s useSelector does. React core may eventually ship this, but as of React 19 it doesn’t.

When context is the wrong tool entirely

Context is for truly app-wide concerns that change rarely: theme, locale, current user, feature flags. It is NOT a state-management library. If your context value updates every keystroke, every scroll, every animation frame — you’ve turned the entire subtree into a re-render storm. Use:

NeedTool
Pass props 2-3 levels downJust pass props
App-wide, rarely-changing stateContext
Frequently-changing global stateRedux / Zustand (with selectors), Jotai (atom-based)
Server state (data fetching, caching)TanStack Query / SWR / RSC
URL stateThe router

Scene 4 · Refs — useRef (and useImperativeHandle)

A ref is a box pinned to the fiber. Mutating ref.current updates the value and does not schedule a render. That single property explains every legitimate use of refs:

Refs & identity — useRef & useImperativeHandle
A box on the fiber that survives renders and never triggers them. Compare with state side-by-side.
Mutating ref.current updates a value but does NOT schedule a render — the DOM stays stale until the component re-renders for some other reason. setState always triggers a render. Two boxes, two stories.
React lifecycle phasenothing to do
idle
event
schedule
render
commit
Phases not visited in a step weren't triggered — React only enters a phase when there's work for it. A ref mutation skips schedule/render/commit (no re-render). Mount skips event/schedule (nothing triggered it). Hover a pill for its meaning.
Code
1function RefBox() {
2 const r = useRef(0);
3 return (
4 <button onClick={() => { r.current += 1; }}>
5 ref ({r.current})
6 </button>
7 );
8}
9 
10function StateBox() {
11 const [n, setN] = useState(0);
12 return (
13 <button onClick={() => setN(n + 1)}>
14 state ({n})
15 </button>
16 );
17}
18 
19function Demo() {
20 return <><RefBox /><StateBox /></>;
21}
Live preview
ref ( 0 )
DOM is in sync with r.current
state ( 0 )
always reflects current state
ref renders0
state renders0
ref mutations0
Fiber internals
useRef
.current: 0
mutable · no render trigger
useState
value: 0
immutable · setter triggers render
DOM ref binding
(unbound)
assigned during commit
Step 0 / 14Pick a scenario and press ▶ Play.
Speed:

useId lives in this same family — it returns a stable string keyed off the fiber’s tree path, used for SSR-safe ARIA and form ids.

Refs — full decision tree, forwardRef, callback refs, and reading the DOM safely click to expand

Ref vs state — a decision flowchart

Need to remember a value across renders? │ yes ────┴──── no → just use a local variable │ Should the UI react when this value changes? ┌────┴────┐ yes no │ │ useState useRef │ │ triggers silent; re-render no render

The simplest test: if you read the value in JSX (or in a derivation that becomes JSX), you need state. If you only read it from event handlers, effects, or imperative APIs, a ref is enough.

What goes in a ref (concrete checklist)

  • Timer / interval IDs → ref. You only need them in cleanup.
  • AbortController for in-flight fetches → ref. You need to cancel from the next call site.
  • The DOM node of an <input>, <canvas>, <dialog> → ref. You call methods on it.
  • A WebSocket / EventSource / non-React library instance → ref. You initialise once, dispose on unmount.
  • The “previous” value of a prop or state, for change detection → ref + useEffect.
  • A flag like mountedRef.current = true to skip work on the first render → ref.
  • Anything you’d put on this in a class component, that doesn’t drive UI → ref.

Refs and DOM nodes — the timing rules

When you write <input ref={inputRef} />, React assigns inputRef.current to the DOM node during commit, before any effects run. Reading order:

  1. In useLayoutEffect → safe. The ref is set, the DOM is mutated, the browser hasn’t painted yet.
  2. In useEffect → safe. Same as above, plus the browser has painted.
  3. In the render body → unsafe. The ref is null on the first render and stale on subsequent ones (until commit). Never read ref.current from render-phase code.
  4. On unmount → both useLayoutEffect cleanup and useEffect cleanup run before React detaches the ref, so ref.current is still readable in either. The trap is the update case: when a child element is re-keyed or replaced between renders, React detaches the old ref before re-running the effect, so a useEffect cleanup that fires for a mid-life update may see ref.current === null. The robust pattern is to capture the node in a local variable inside setup and close over that — your cleanup gets the right node regardless of when it runs.

forwardRef + useImperativeHandle

By default, refs only attach to host elements (<div>, <input>, etc.). To pass a ref through a function component to a child in React ≤ 18, wrap the component in forwardRef:

const FancyInput = forwardRef(function FancyInput(props, ref) {
return <input ref={ref} className="fancy" {...props} />;
});
// Now the parent can do:
const r = useRef(null);
<FancyInput ref={r} />;
r.current?.focus(); // forwarded all the way to <input>

In React 19+, forwardRef is no longer required — ref is just a regular prop on function components:

function FancyInput({ ref, ...props }) {
return <input ref={ref} className="fancy" {...props} />;
}

forwardRef still works for backward compatibility, but new code should prefer the prop form.

useImperativeHandle adds a layer of curation between parent and child: instead of exposing the raw DOM node, the child decides what methods are available.

// React 19+: ref is a regular prop, no forwardRef wrapper.
function Modal({ ref, children }) {
const [open, setOpen] = useState(false);
// Stable handle — closures see the latest setOpen (its identity is stable
// across renders), so empty deps are correct.
useImperativeHandle(ref, () => ({
open: () => setOpen(true),
close: () => setOpen(false),
}), []);
return open ? <dialog>{children}</dialog> : null;
}

When is this worth it? Almost never in app code. It’s an escape hatch for component libraries that need to expose a controlled imperative API (think: a <Modal />, a <Toast /> system, a video player). If you reach for it in regular product code, you’re probably fighting React’s data flow — lift state up instead.

Callback refs — the underused alternative

ref doesn’t have to be a useRef object. It can be a function. React calls your function with the DOM node on mount and null on unmount. If the callback’s identity changes between renders (e.g. an inline arrow without useCallback), React calls the old function with null and the new function with the node on every render — which is why wrapping the callback in useCallback matters.

function MeasuredBox() {
const [height, setHeight] = useState(0);
const measureRef = useCallback((node) => {
if (node) setHeight(node.getBoundingClientRect().height);
}, []);
return <div ref={measureRef}>height: {height}</div>;
}

Callback refs are the cleanest way to measure a node when it mounts (no extra effect needed, no race conditions) or to wire up to a node whose presence is conditional. In React 19+, a callback ref can return a cleanup function instead of being called again with null — mirroring useEffect:

const measureRef = useCallback((node) => {
// React 19+ guarantees `node` is the DOM node here; the cleanup
// returned below replaces the legacy "called again with null" path.
const ro = new ResizeObserver(/* … */);
ro.observe(node);
return () => ro.disconnect();
}, []);

The official docs page ref callbacks covers the lifecycle in detail.

Scene 5 · Memoization — useMemo & useCallback

A useMemo is a per-fiber cache keyed on a tuple. Each render, React runs Object.is on every cell of the deps array. If every cell matches the previous tuple, the cached value is reused. If any cell mismatches, the function runs again and the result replaces the cache.

Memoization — useMemo & useCallback
A cache keyed by Object.is on every dep cell. Hits and misses, live.
When deps are primitives that don't change, Object.is returns true for every cell — useMemo reuses the cached value. The expensive function runs once and the memo'd Plot child skips its render.
React lifecycle phasenothing to do
idle
event
schedule
render
commit
Phases not visited in a step weren't triggered — React only enters a phase when there's work for it. A ref mutation skips schedule/render/commit (no re-render). Mount skips event/schedule (nothing triggered it). Hover a pill for its meaning.
Code
1const Plot = React.memo(function Plot({ data }) {
2 return <svg>{/* heavy chart */}</svg>;
3});
4 
5function Chart({ a, b }) {
6 const series = useMemo(
7 () => expensiveCompute(a, b), // runs only when deps change
8 [a, b]
9 );
10 return <Plot data={series} />;
11}
Live preview
<Parent />
useMemo result: (empty)
<Child memo />
onClick: fn#0
parent renders0
child renders0
Dependency comparison & cache
Deps array — Object.is per cell
(no deps)
Cache box
(empty)idle
hit (skip recompute) miss (run the function)
Step 0 / 16Pick a scenario and press ▶ Play.
Speed:

Two failure modes account for most useless useMemo/useCallback usage:

useCallback(fn, deps) is literally useMemo(() => fn, deps). Same machinery, different ergonomics.

When memoization actually pays — and when it costs more than it saves click to expand

The cost you don’t see

Every useMemo and useCallback is itself work. On every render, React must:

  1. Read the hook record from the slot list.
  2. Run Object.is on every cell of the deps array, comparing with the previous tuple.
  3. Either skip the function (cache hit) or run it and store the new value + new deps tuple (cache miss).

For a function that returns a primitive or runs a few lines of code, steps 1-2 cost more than just running the function unconditionally. Memoising useMemo(() => a + b, [a, b]) is a strict performance loss. The official React docs are blunt about this in Should you add useMemo everywhere?: the answer is no.

When to use it

Use useMemo when at least one of these is true:

  1. The computation is genuinely expensive — sorting/filtering thousands of items, parsing a large string, deep traversal of a tree, image processing.
  2. The result is passed as a prop to a React.memo child and you need referential stability for that child to skip its own render.
  3. The result is used as a dep in another hook (useEffect, useMemo, useCallback) where reference instability would cause spurious re-runs.

Anything else is cargo-cult premature optimisation. Profile first.

Use useCallback only when:

  1. The function is passed to a React.memo child as onClick / onChange / similar handler.
  2. The function is used as a dep in another hook (the most common legitimate case — useEffect that depends on a callback).
  3. You’re building a custom hook and need to expose a stable function reference to consumers.

The decision matrix

SituationuseMemo/useCallback?
Computing a + bNo
Sorting a list of 10 itemsNo
Sorting a list of 10,000 itemsYes
Filtering a search result list (heavy regex / fuzzy match)Yes
Building props for a non-memoised childNo (the child re-renders anyway)
Building props for a React.memo childYes — needed for the memo to actually skip
useEffect(() => { /* uses cb */ }, [cb]) where cb is defined in renderYes — wrap cb in useCallback
A handler passed to a regular <button onClick>No

The React Compiler changes the math

React 19 was designed to pair with the React Compiler (formerly “React Forget”) — a separate Babel/SWC plugin (babel-plugin-react-compiler, stable 1.0 since late 2025) that you opt into in your build. It automatically inserts memoisation at compile time, based on a static analysis of which values change and which children depend on them. With the compiler enabled, most hand-written useMemo/useCallback calls become noise — the compiler does it better and at the right granularity.

If you’re on React 19+ with the compiler enabled, the new rule of thumb is: don’t write useMemo/useCallback unless the compiler can’t reason about your code (rare — usually involves dynamic indirections or escape hatches). If you haven’t enabled the compiler, the matrix above still applies.

Spotting useless memo in code review

Look for these signs:

  • useMemo(() => ({ ... }), [...]) where the object is consumed by a non-memo’d child → useless.
  • useCallback(() => ..., []) for a handler passed to <button> → useless.
  • useMemo(() => primitiveExpression, [...]) → almost always useless.
  • A long chain of useCallbacks wrapping each other “for stability” → likely fighting symptoms; move state up or use a reducer.
  • useMemo with [] as deps for something that doesn’t depend on render → use useRef with the lazy-init idiom (if (ref.current === null) ref.current = init()), not a memo. (useRef(init()) would call init() on every render — same trap as useState(init()).)

Scene 6 · Concurrent — useTransition (and useDeferredValue)

React’s scheduler has two priorities: urgent (input, clicks — must commit immediately) and transition (heavy work that the user can wait a frame for). useTransition marks a setState as transition; the scheduler runs all urgent work first and is free to interrupt a transition mid-render if a new urgent task arrives.

Concurrent — useTransition & useDeferredValue
Two priority lanes. Urgent always wins. Transitions can be interrupted and restarted.
An input update is urgent — it must show every keystroke without lag. The filtered list is heavy — it's wrapped in startTransition. When the user types fast, the in-flight list render is THROWN AWAY and a new one starts. The input never stutters.
React lifecycle phasenothing to do
idle
event
schedule
render
commit
Phases not visited in a step weren't triggered — React only enters a phase when there's work for it. A ref mutation skips schedule/render/commit (no re-render). Mount skips event/schedule (nothing triggered it). Hover a pill for its meaning.
Code
1function Search() {
2 const [text, setText] = useState("");
3 const [list, setList] = useState(allItems);
4 const [pending, startTransition] = useTransition();
5 
6 function onChange(e) {
7 setText(e.target.value); // urgent
8 startTransition(() => {
9 setList(filterHeavy(allItems, e.target.value)); // transition
10 });
11 }
12
13}
Live preview
search: (type)|
Heavy filtered list
(awaiting transition…)
Scheduler lanes (urgent vs transition)
urgent lanenever interruptible · runs first
empty
transition laneinterruptible · runs when urgent is empty
empty
running done interrupted (discarded)
Step 0 / 24Pick a scenario and press ▶ Play.
Speed:

This is the part of React that everyone agrees they should use and almost no one actually does. The rule of thumb: any setState that triggers an expensive render and whose result the user can wait one frame for belongs inside startTransition. Filtered list updates, route transitions, anything where the underlying input update must remain at 60fps while the heavy thing catches up.

useDeferredValue is the same idea expressed as a value transformation rather than a setter wrapper — useful when you receive a value from props and don’t control the setState yourself.

Concurrent rendering — time-slicing, Suspense interplay, and common antipatterns click to expand

What “concurrent” actually means

In legacy React, a render was a single synchronous task. Once it started, it ran to completion — even if it took 100ms, the main thread was blocked the whole time. Click events queued. Scroll stuttered. Input lagged.

Concurrent React (since React 18) splits a render into time-slices. The scheduler asks “do I have more high-priority work?” between slices. If yes — say, the user typed — it pauses the in-flight render, handles the urgent work, and either resumes or throws away the partial work and starts over. From the user’s perspective, the input never blocks. From your code’s perspective, render must be safe to be paused, replayed, or discarded — which is why React enforces purity: no side effects in the render phase.

Legacy render Concurrent render ───────────── ───────────────── ┌──────────────┐ ┌──┐ ┌──┐ ┌──┐ ┌──┐ │ render │ 100ms blocked │ 5│ │ 5│ │ 5│ │ 5│ 5ms slices │ │ └──┘ └──┘ └──┘ └──┘ └──────────────┘ ↑ ↑ (user (user clicks) typed) └─→ pause, handle, resume └─→ interrupt, restart

Two priority levels (today)

React internally has many lanes, but as an app developer you mostly think in two:

  • Urgent — anything not wrapped in startTransition. Includes: input updates from controlled <input>, click handlers, default setState. Must commit ASAP.
  • Transition — work wrapped in startTransition or driven by useDeferredValue. Interruptible. The scheduler runs transition work only when no urgent work is pending.

useTransition vs useDeferredValue — pick one

You have…Use
Direct control over the heavy setStateuseTransition — wrap the setState in startTransition
Only the value (it comes from props or a hook you don’t own)useDeferredValue(value) — produces a “lagged” copy
A heavy useMemo derived from propsuseDeferredValue on the input value, then useMemo on the deferred value
// useTransition — you own the setState
const [pending, startTransition] = useTransition();
function onChange(e) {
setText(e.target.value); // urgent
startTransition(() => {
setHeavyList(filter(items, e.target.value)); // transition
});
}
// useDeferredValue — you only have the value
function ResultsView({ query }) {
const deferred = useDeferredValue(query); // lags during heavy work
const list = useMemo(() => filter(items, deferred), [deferred]);
const isStale = deferred !== query;
return <List items={list} className={isStale ? "stale" : ""} />;
}

Suspense and transitions are best friends

The killer feature of transitions is what happens when the new render suspends (hits a <Suspense> boundary because data is loading). Without transitions, React shows the boundary’s fallback — your beautiful page is replaced by a spinner. With transitions, React keeps the previous UI on screen and only swaps in the new one when it’s ready, with isPending true the whole time so you can show a subtle pending indicator.

const [pending, startTransition] = useTransition();
function navigate(href) {
startTransition(() => {
setRoute(href); // triggers a Suspense'd data fetch
});
}
// While loading: previous page stays visible, isPending=true,
// no fallback flash. When data arrives: smooth swap.

This is the entire mechanism behind modern router transitions in Next.js App Router and Remix — they wrap navigation setStates in startTransition.

Common antipatterns

  • Wrapping trivial setStates in startTransition. Setting a boolean toggle in a transition adds overhead with no benefit; the scheduler has nothing to optimise. Use it only when the resulting render is genuinely heavy.
  • Wrapping the input update itself in startTransition. The input THEN lags. The whole point is that the input stays urgent and the result is the transition. Pattern: setText urgent, setFiltered transition.
  • Using useDeferredValue and useTransition together on the same data path. Pick one. They’re alternative APIs for the same scheduling primitive.
  • Putting effects-based work in transitions and expecting it to be interruptible. Effects run in the commit phase, after a render commits. They’re not part of the time-sliced render. If your bottleneck is an effect, move the work into render and memoise it (or move it out of React entirely with a worker).

What concurrent rendering does NOT solve

Concurrent rendering keeps the main thread responsive; it does not make slow code fast. If your filter function takes 500ms, the transition lane will still take 500ms — the user just won’t notice the lag in the input. For genuinely heavy compute, the answer is a Web Worker. Concurrent React only buys you better scheduling of the work React itself does.

Scene 7 · Predict the behavior

The only real test of a mental model is whether you can use it to predict what a program does before running it. Five quizzes of varying difficulty. Click the events in the order React will run them, then hit Check.

Predict the behavior
Read the code. Click the events in the order React will run them.
mediumEffect cleanup order
<Parent /> mounts <Child />. Both have a useEffect with setup + cleanup. The user unmounts <Parent />. Order ALL the log lines, mount through unmount.
function Parent() {
  useEffect(() => {
    console.log("setup parent");
    return () => console.log("cleanup parent");
  }, []);
  return <Child />;
}

function Child() {
  useEffect(() => {
    console.log("setup child");
    return () => console.log("cleanup child");
  }, []);
  return <span>hi</span>;
}

// then: parent unmounts.
Available events — click in predicted order
Your predicted order
(click events above)

If “Effect cleanup order” and “useTransition: scheduler order” went down on the first try, the model is solid. If not, scrub back through the relevant scene above and reason your way through — the visualizers depict the underlying mechanism each quiz tests.

Honourable mentions

A handful of hooks didn’t get their own visualizer because their behavior is straightforward once the rest is in place:

Pitfalls cheat sheet

SymptomCauseThe visualizer that proves it
Three setState calls produce one render with the wrong valueCapturing closures, not functional updatersScene 1 — Three increments, one render
Subscription leaks across re-rendersCleanup not declared, or deps array wrongScene 2 — Deps change → cleanup before next setup
Memo’d component re-renders anywayIt consumes a context that changedScene 3 — Provider value change
Mutating ref.current doesn’t update the UIRefs deliberately don’t trigger rendersScene 4 — useRef vs useState
useMemo runs the expensive function every renderObject literal in the deps arrayScene 5 — Object dep recreated each render
Memo’d child re-renders despite matching propsThe onClick is a fresh arrow each renderScene 5 — useCallback keeps child stable
Input lags during a heavy filterFilter is urgent, should be a transitionScene 6 — Transition gets interrupted
Interval logs the same stale value foreverEmpty deps captured the initial stateQuiz — setInterval inside useEffect([])

What hooks aren’t

Hooks aren’t magic. They’re a small linked list on the fiber, a render phase that walks it in order, and a commit phase that flushes the diff. Everything labelled “advanced” — concurrent rendering, transitions, optimistic updates — is built on top of that loop without changing it. Once the loop is concrete in your head, the rest of React stops being mysterious and starts being predictable.

Which is, ultimately, the only thing you actually need from a framework.