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
- Scene 1 · State —
useState&useReducer - Scene 2 · Effects —
useEffect(and friends) - Scene 3 · Context —
useContext - Scene 4 · Refs —
useRef(anduseImperativeHandle) - Scene 5 · Memoization —
useMemo&useCallback - Scene 6 · Concurrent —
useTransition(anduseDeferredValue) - Scene 7 · Predict the behavior
- Honourable mentions
- Pitfalls cheat sheet
- What hooks aren’t
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.
A render in React is split into two phases:
-
Render phase (pure) — React calls your component function. It walks the hook list in source order, returning whatever’s in each slot. If
setStatewas 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). -
Commit phase (synchronous, side-effecting) — React applies the mutations to the real DOM and runs
useLayoutEffectcallbacks. This phase is uninterruptible. The browser then paints. After paint, React firesuseEffectcallbacks 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.
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).
1function Counter() {2 const [n, setN] = useState(0);3 return (4 <button onClick={() => setN(n + 1)}>5 count: {n}6 </button>7 );8}
A few rules these scenarios make obvious:
- Slots are positional. React identifies a hook by call order, not name. Wrap a
useStatein anifand the next hook’s slot shifts under it on the wrong render — silently, catastrophically. That’s the entire reason for the Rules of Hooks. - Updates queue, then render drains. Three setter calls in one event handler don’t render three times. They batch. The visualizer shows the safe form
setN(n => n + 1)producing+3because each updater reads from the latest pending value. The unsafe formsetN(n + 1)would produce just+1— every call captures the samenfrom the closure of the current render. useReducerisuseStatewith the update logic centralised. Same slot, same queue. Reach for it when state transitions are non-trivial enough that a switch statement reads better than threesetXcalls in a row.
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.
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 timessetN(n + 1);setN(n + 1); // still computing 0 + 1setN(n + 1); // still computing 0 + 1
// Fix: each sees the latest pendingsetN(prev => prev + 1); // 0 → 1setN(prev => prev + 1); // 1 → 2setN(prev => prev + 1); // 2 → 3useState vs useReducer — the decision matrix
| Situation | Pick |
|---|---|
| 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 transitions | useReducer — 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 ways | useReducer — co-locates the rule, not the call sites |
| You’re about to wire the same state into several event handlers | useReducer — dispatch({ 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.
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.
1function Greeter() {2 useEffect(() => {3 console.log("setup");4 return () => console.log("cleanup");5 }, []); // [] → mount once, unmount once6 return <h1>hi</h1>;7}
Three things worth burning in:
useEffectruns after paint. Pixels reach the user before your side-effect runs. Good for analytics, subscriptions, network — bad for any DOM measurement that must happen before the user sees a flicker.useLayoutEffectruns before paint. Synchronous, blocking. The right tool for measuring layout, repositioning a tooltip, or animating without flicker. The wrong tool for anything that doesn’t need to happen before the next frame, because it blocks the paint.- Cleanup runs first when deps change.
useEffect(() => connect(id), [id])first calls the previous cleanup (disconnect(oldId)), then runs the new setup (connect(newId)). Stale subscriptions are React’s failure mode of choice; this is how it avoids them.
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).
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 intervaluseEffect(() => { const id = setInterval(tick, 1000); // BUG: no cleanup → multiple intervals stack up}, []);// Fix: return () => clearInterval(id)
// 2) Forgot deps array → effect runs every renderuseEffect(() => { 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 shapeuseEffect(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 ituseEffect(() => { 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 loopuseEffect(() => { 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 listeneruseEffect(() => { 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 refWhen 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 wrote | What 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 React | Prefer 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 flicker →
useLayoutEffect(synchronous, blocks paint, pays the perf cost). - No, it’s invisible →
useEffect(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.
1const Theme = createContext("light");23function 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}1213function 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}
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.
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 objectconst 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:
| Need | Tool |
|---|---|
| Pass props 2-3 levels down | Just pass props |
| App-wide, rarely-changing state | Context |
| Frequently-changing global state | Redux / Zustand (with selectors), Jotai (atom-based) |
| Server state (data fetching, caching) | TanStack Query / SWR / RSC |
| URL state | The 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:
1function RefBox() {2 const r = useRef(0);3 return (4 <button onClick={() => { r.current += 1; }}>5 ref ({r.current})6 </button>7 );8}910function StateBox() {11 const [n, setN] = useState(0);12 return (13 <button onClick={() => setN(n + 1)}>14 state ({n})15 </button>16 );17}1819function Demo() {20 return <><RefBox /><StateBox /></>;21}
- Mutable values that don’t drive UI — interval IDs, last-known scroll positions, throwaway counters. Use a ref. Using state would re-render the world for nothing.
- DOM handles —
<input ref={r} />writes the DOM node intor.currentafter commit. Now you can callr.current.focus(),getBoundingClientRect(), or hand the node to a non-React library. - Controlled child APIs —
useImperativeHandlelets a child decide what its ref exposes. The parent gets{ open, close }instead of a raw DOM node, and the child preserves encapsulation.
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
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 = trueto skip work on the first render → ref. - Anything you’d put on
thisin 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:
- In
useLayoutEffect→ safe. The ref is set, the DOM is mutated, the browser hasn’t painted yet. - In
useEffect→ safe. Same as above, plus the browser has painted. - In the render body → unsafe. The ref is
nullon the first render and stale on subsequent ones (until commit). Never readref.currentfrom render-phase code. - On unmount → both
useLayoutEffectcleanup anduseEffectcleanup run before React detaches the ref, soref.currentis 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 auseEffectcleanup that fires for a mid-life update may seeref.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.
1const Plot = React.memo(function Plot({ data }) {2 return <svg>{/* heavy chart */}</svg>;3});45function Chart({ a, b }) {6 const series = useMemo(7 () => expensiveCompute(a, b), // runs only when deps change8 [a, b]9 );10 return <Plot data={series} />;11}
Two failure modes account for most useless useMemo/useCallback usage:
- Object literals as deps —
[{ a, b }]is a new reference every render. The cache misses every time. Pass the primitives directly:[a, b]. useCallbackwithout a memo’d consumer — the only reason to wrap a function is to keep its referential identity stable so aReact.memochild can skip its render. If the child isn’t memo’d, you’ve added overhead for nothing.
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:
- Read the hook record from the slot list.
- Run
Object.ison every cell of the deps array, comparing with the previous tuple. - 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:
- The computation is genuinely expensive — sorting/filtering thousands of items, parsing a large string, deep traversal of a tree, image processing.
- The result is passed as a prop to a
React.memochild and you need referential stability for that child to skip its own render. - 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:
- The function is passed to a
React.memochild asonClick/onChange/ similar handler. - The function is used as a dep in another hook (the most common legitimate case —
useEffectthat depends on a callback). - You’re building a custom hook and need to expose a stable function reference to consumers.
The decision matrix
| Situation | useMemo/useCallback? |
|---|---|
Computing a + b | No |
| Sorting a list of 10 items | No |
| Sorting a list of 10,000 items | Yes |
| Filtering a search result list (heavy regex / fuzzy match) | Yes |
| Building props for a non-memoised child | No (the child re-renders anyway) |
Building props for a React.memo child | Yes — needed for the memo to actually skip |
useEffect(() => { /* uses cb */ }, [cb]) where cb is defined in render | Yes — 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. useMemowith[]as deps for something that doesn’t depend on render → useuseRefwith the lazy-init idiom (if (ref.current === null) ref.current = init()), not a memo. (useRef(init())would callinit()on every render — same trap asuseState(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.
1function Search() {2 const [text, setText] = useState("");3 const [list, setList] = useState(allItems);4 const [pending, startTransition] = useTransition();56 function onChange(e) {7 setText(e.target.value); // urgent8 startTransition(() => {9 setList(filterHeavy(allItems, e.target.value)); // transition10 });11 }12 …13}
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.
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, defaultsetState. Must commit ASAP. - Transition — work wrapped in
startTransitionor driven byuseDeferredValue. 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 setState | useTransition — 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 props | useDeferredValue on the input value, then useMemo on the deferred value |
// useTransition — you own the setStateconst [pending, startTransition] = useTransition();function onChange(e) { setText(e.target.value); // urgent startTransition(() => { setHeavyList(filter(items, e.target.value)); // transition });}
// useDeferredValue — you only have the valuefunction 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:
setTexturgent,setFilteredtransition. - Using
useDeferredValueanduseTransitiontogether 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.
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.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:
useId— returns a stable, SSR-safe string per call site. Use it for<label htmlFor>/ ARIA pairs. Never use it as a list key.useDebugValue— DevTools-only label for custom hooks. Production no-op. Adds a friendlier name when you hover the hook in React DevTools.useInsertionEffect— runs synchronously before DOM mutations. Exists for CSS-in-JS libraries (styled-components, Emotion) that need to inject<style>tags before the layout effects run. You will probably never write one.useSyncExternalStore— the official adapter for stores that live outside React (Redux, Zustand, browser APIs). You give React asubscribefunction and agetSnapshotfunction; React handles tearing-free reads under concurrent rendering.useActionState— React 19. Wraps a server/form action and exposes[state, formAction, isPending]. The state is the result of the last action invocation;isPendingis true while the action is in flight. Pairs naturally with the<form action={formAction}>shorthand.useOptimistic— React 19. Renders an optimistic version of state immediately, automatically reverts when the underlying real state catches up (or the action errors). Removes most of the manual “pending UI + rollback” plumbing for forms.
Pitfalls cheat sheet
| Symptom | Cause | The visualizer that proves it |
|---|---|---|
Three setState calls produce one render with the wrong value | Capturing closures, not functional updaters | Scene 1 — Three increments, one render |
| Subscription leaks across re-renders | Cleanup not declared, or deps array wrong | Scene 2 — Deps change → cleanup before next setup |
| Memo’d component re-renders anyway | It consumes a context that changed | Scene 3 — Provider value change |
Mutating ref.current doesn’t update the UI | Refs deliberately don’t trigger renders | Scene 4 — useRef vs useState |
useMemo runs the expensive function every render | Object literal in the deps array | Scene 5 — Object dep recreated each render |
| Memo’d child re-renders despite matching props | The onClick is a fresh arrow each render | Scene 5 — useCallback keeps child stable |
| Input lags during a heavy filter | Filter is urgent, should be a transition | Scene 6 — Transition gets interrupted |
| Interval logs the same stale value forever | Empty deps captured the initial state | Quiz — 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.