FrameworksFRAMEWORK-SPECIFICSPEC-EVOLVING

The React Mental Model

State and props go in, the component function runs and returns a description of the UI, a reconciler compares descriptions, and the difference becomes DOM calls.

The intent, the obvious build, and why it breaks

Every lesson starts where the work starts: someone wanted an outcome, and the first implementation that comes to mind has a problem.

The question

What actually happens between calling a state setter and a pixel changing in React?

The user intent

Someone clicks a filter chip. They expect the list beneath it to change, keep their scroll position, and not lose what they had typed in the search box above it.

The obvious build

A state setter updates the value and React updates the part of the screen that shows it. The component is an object with a render method that React calls when the object changes.

Why it breaks

The setter does not update the value. Read it on the next line and it is still the old one — the update is scheduled, and the new value is visible only to the next run of the function (Debugging State).

How it breaks in a real browser
  • The setter does not update the value. Read it on the next line and it is still the old one — the update is scheduled, and the new value is visible only to the next run of the function (Debugging State).
  • There is no object being mutated. The component is a function that runs again from the top, and every value inside it — including every function you defined — is a fresh one from that run.
  • React does not update "the part of the screen that shows it". It re-runs the component that owns the state and, by default, everything beneath it, then works out what differs.
  • The mental model breaks loudly the first time a closure captures a value from an old run: an interval handler that keeps reporting the count as it was when the interval was created.
  • "It re-renders" and "the DOM changed" are different events. A component can re-render on every keystroke while producing an identical description, in which case the reconciler commits nothing and the DOM is untouched — the cost was all JavaScript.
IntentEventStateUI LogicDOM WorkNetworkLayout / PaintPixelsFeedback

What is actually happening

In the browser, not in the framework.

  • State and props are the inputs. A component is a function of them. Given the same inputs it must produce the same output, which is what makes re-running it a valid strategy at all.
  • Render is calling that function. It returns a tree of plain objects describing elements — type, props, children. Nothing has touched the DOM at this point, and nothing is guaranteed to.
  • The description is not the UI. It is a value: cheap to create, cheap to throw away, and comparable to the previous one. This indirection is the whole design.
  • Reconciliation compares the two descriptions. Same position and same type means update this node in place; different type means unmount the old subtree and mount a new one; a keyed list is matched by key rather than by position (Reconciliation and Keys).
  • Commit applies the differences. This is the only phase that touches the DOM, and it is synchronous with respect to the browser: the mutations happen, then the browser gets its rendering opportunity (The Rendering Opportunity).
  • Effects run after commit. Anything that must observe the committed DOM — measuring, focusing, subscribing — belongs there, because during render the DOM still reflects the previous description (Focus Management).

What this makes the browser do

And which of it is avoidable.

  • Running component functions and building element objects: pure main-thread JavaScript, proportional to the number of components in the re-rendered subtree, not to the size of the change (What the Main Thread Owns).
  • Comparing two descriptions: also main-thread, also proportional to the subtree, and also happening whether or not anything differs.
  • The commit: ordinary DOM calls whose cost is the browser's, and identical to what the same calls would cost written by hand (What a Mutation Costs).
  • Allocation and garbage: a render pass creates objects that become garbage moments later. It is cheap per object and it is not free at high frequency (Memory Leaks).
  • Avoidable work: subtree re-renders caused by a new object or function identity passed as a prop, when the child would have been happy with the previous one.

The five stages, named

The reason to learn these as separate stages is that bugs live between them. "It did not update" is a stage-one problem; "it updated and the DOM did not change" is stage four; "it flickered" is stage five. Merging them into "React re-renders" makes all three the same undiagnosable sentence.

The stage boundary that matters most is between describing and committing. During render you are producing a value; the DOM at that moment still shows the previous one. Everything that needs the new DOM — measuring, focusing, scrolling — has to wait until after the commit.

State to pixels
  1. 1
    State / props change

    A setter schedules an update and marks the owning component dirty. The current render finishes unaffected.

    fails by Mutating an object in place instead of replacing it: nothing is scheduled, and the value on screen and the value in memory diverge.

  2. 2
    Render

    The component function runs again from the top, along with its children unless a memo boundary stops the descent.

    fails by Expensive derivation inline — sorting or formatting thousands of rows — paid on every pass rather than when the input changed.

  3. 3
    UI description

    The function returns a tree of plain objects: element type, props, children. No DOM has been touched.

    fails by Creating a new object or function identity for a prop, which makes an otherwise-equal child look changed.

  4. 4
    Reconciliation

    The new tree is compared with the previous one, position by position, keyed children by key, to produce a minimal set of operations.

    fails by Unstable keys or a changed element type, which turns an update into an unmount plus a mount — losing DOM state, focus and any running animation.

  5. 5
    Commit + effects

    DOM operations are applied, then effects run against the committed DOM.

    fails by An effect that sets state unconditionally, starting the cycle again and costing an extra frame every time.

Only the last step touches the browser. The first four are JavaScript, and on a slow device they are usually the larger half.

The function runs again — all of it

This is the single idea that makes the rest of React predictable. There is no persistent component object whose fields you are mutating. Every render creates new local values, new closures, and new inline functions, and the only things that survive between renders are the ones you explicitly asked to survive.

Once that is internalised, the classic bugs stop being mysterious. A callback registered once holds the values from the render that created it. A value recomputed on every render is a new value even when it is equal to the old one. A child that "should not have re-rendered" received a prop that was structurally identical and referentially new.

What survives a render, and what does not
1function Counter({ step }: { step: number }) {
2 // New on every render: `count` is this render's value,
3 // `increment` is this render's function object.
4 const [count, setCount] = useState(0)
5 const increment = () => setCount(count + step)
6
7 // Survives renders: the ref object identity, and the state
8 // React holds for this component instance. Nothing else does.
9 const renders = useRef(0)
10 renders.current += 1
11
12 useEffect(() => {
13 // Registered once. It closed over `count` from the render
14 // that ran it — which was the first one, forever.
15 const id = setInterval(() => console.log(count), 1000)
16 return () => clearInterval(id)
17 }, [])
18
19 return <button onClick={increment}>{count}</button>
20}

The interval is the whole lesson: it does not read a variable that changes, it holds a value from a render that is over. The functional setter form exists because it asks React for the current value instead of capturing one.

Five symptoms and what they actually are

Each row below is a real report from a real codebase, translated into the stage it belongs to. The translation is the skill: once a symptom is attached to a stage, the fix is usually obvious and is almost never "add a memo".

Symptom to stage
TriggerSymptomCauseResponse
Setter called, value logged on the next lineThe old value is printedThe update was scheduled; the current render's binding is immutableRead the new value in the next render, or use the functional setter if the update depends on the previous value.
An array is pushed into and state is set to the same arrayNothing updatesThe reference did not change, so the component was never marked dirtyReplace rather than mutate. This is the price of making change detection a reference comparison.
A row is deleted from a list keyed by indexAnother row's checkbox or text input carries the wrong valueReconciliation matched by key, and the keys shifted under the dataKey by a stable identifier from the data (Reconciliation and Keys).
A memoized child re-renders on every parent renderProfiler shows it rendering with identical propsAn inline object, array or arrow function prop is a new identity each passStabilise the prop, or accept the render — measure which one is actually costing anything (What a Component Costs to Render).
Focus jumps to the top of the page after an updateKeyboard users lose their place; screen reader restartsThe focused node was unmounted — a changed key or a changed element typeKeep the element identity stable across the update, or restore focus explicitly after commit (Focus Management).

How to build it

Most important first.

  • Put state at the level that owns it, not at the level that is convenient. State placed too high re-renders a subtree on every change; state duplicated downward gets out of sync (Who Owns This State?).
  • Derive during render instead of storing. A value computed from props and state cannot go stale, and an effect that copies one piece of state into another is a synchronisation bug waiting for a race (Derived State).
  • Give lists stable keys drawn from the data's identity. Index keys are the single most common way to make React reuse the wrong node (Reconciliation and Keys).
  • Treat effects as a way to reach outside React — subscriptions, measurements, imperative focus — and not as a place to compute values.
  • Reach for memoization only after profiling shows the re-render is real and costly, and remember that the comparison itself runs on every render (Memoization).

Keyboard, focus, semantics, announcement

A required field on every lesson in this domain, not a section added when there is room.

  • Focus lives on a DOM node, and reconciliation can replace that node. When a conditional swaps element types or a key changes, focus moves to the body silently and a keyboard user is stranded at the top of the document (Focus Management).
  • Focus work belongs in an effect, after commit. Calling focus() during render targets the previous DOM, and either does nothing or focuses the wrong element.
  • Portals move DOM out of the tree while leaving the React tree intact, so keyboard order and the DOM order can disagree. A dialog rendered at the document root needs its focus trap and its return-focus target managed explicitly (Accessible Component Patterns).
  • Re-rendering a live region's container can re-announce content that did not change. Update the text inside a stable region rather than replacing the region (Live Regions and Announcement).
  • Conditional rendering that unmounts a component containing an error message removes it from the accessibility tree with no announcement. Errors need to be associated with their input and announced deliberately (Errors People Can Actually Perceive).

What can go wrong

Failure modes
  • Stale closures: a callback stored somewhere long-lived captures the values from the render that created it and keeps reporting them long after they are wrong.
  • Effect cascades: an effect sets state, which triggers a render, which triggers the effect. It usually converges, sometimes after several extra frames, and always costs them.
  • Identity churn: an inline object or function prop makes every child render again, which quietly cancels every memoization boundary below it.
  • Reused nodes with the wrong content, from unstable or index keys — the failure that corrupts state rather than merely slowing things down.
  • Memoization that costs more than it saves: a useMemo around a trivial computation adds a dependency comparison and retained memory in exchange for arithmetic that was already free.
What can arrive out of order
  • Two state updates from different sources in the same task are batched into one render, so a component never observes the intermediate state — which is usually what you want and occasionally surprising.
  • An asynchronous response can arrive after the component that requested it has unmounted, or after a newer request for the same thing. Neither the framework nor the reactivity model will order them for you (Out-of-Order Responses).
Security
  • Text interpolated into JSX is escaped, which closes the common injection path but not the concept (Cross-Site Scripting).
  • The raw-HTML escape hatch is deliberately awkward to type and completely effective at bypassing that escaping. Sanitise before it, never after (Sanitization and Trusted HTML).
  • Props are not a trust boundary. A component that renders a URL from data can be handed a javascript: URL, and the framework will not object (Sanitization and Trusted HTML).
  • Server rendering means your component code runs on a server: anything read from module scope there, including environment variables, can end up serialised into the HTML (Server-Side Rendering).
Misreads
  • "A re-render is a DOM update." It is a function call producing a description. If the description matches, nothing reaches the DOM at all.
  • "React is fast because of the virtual DOM." The reconciler is faster than throwing away and rebuilding a subtree, and slower than writing to exactly the node that changed. It buys the declarative model — that is what it is for.
  • "State updates are synchronous." They are scheduled. Reading state immediately after setting it gives the value from the current render, every time.
  • "Effects run when the component renders." They run after commit, and their dependency list controls re-running, not running — a distinction that generates most of the effect bugs in the wild.
  • "Memo everywhere is free insurance." Every memo is a comparison and a retained reference, and one unstable prop above it makes the whole boundary decorative (Memoization).

Measuring it, and what changes in the field

How you would see this
  • The React Profiler records which components rendered in a commit and what triggered it — the fastest way to find a subtree re-rendering for no reason.
  • The Performance panel shows the same commit as main-thread time, followed by the style and layout the mutations caused (A Mental Model of the Devtools).
  • Interaction latency in the field tells you whether any of it reaches a user. A component that renders twice as often as it needs to on a fast machine may never appear in field data (Interaction Responsiveness).
Slow device, slow network, large data, old tab
  • On a slow device, the render and diff phases scale with CPU while the DOM commit scales with the number of mutations, so the same interaction can be JavaScript-bound on a phone and mutation-bound on a laptop.
  • On a large list, subtree re-render cost dominates everything else, and virtualisation is a bigger win than any amount of memoization (List Virtualization).
  • Under server rendering, the same component function runs in an environment with no DOM, no window and no layout, so anything that assumes the browser must be deferred to an effect (Hydration).
What this costs
  • "UI is a function of state" is an unusually good mental model, and it is paid for with work proportional to the tree instead of to the change. Every escape hatch in React exists to buy some of that back.
  • Immutable update discipline makes change detection trivial and makes deep updates verbose. The verbosity is real; the alternative is a change nobody can detect.
  • Effects give you a principled boundary with the outside world and are the hardest part of the model to teach, because their dependency rules encode the re-run semantics rather than any browser behaviour.

Where this applies

Frontend advice ages badly and fragments across engines. These labels say what each claim is specific to, and where a different browser, device or framework would differ.

  • FRAMEWORK-SPECIFICRe-running the component function and diffing its return value is React's strategy specifically. Vue re-runs a component only when a reactive value it read has changed, Svelte and Solid never re-run the component body after the first pass, and Angular checks bindings against a compiled view rather than producing a description to compare.
  • SPEC-EVOLVINGReact's scheduling and memoization story has moved repeatedly — concurrent rendering, transitions, and a compiler that inserts memoization automatically. The render-describe-reconcile-commit sequence has been stable throughout; the advice about when to memoize by hand has not.

Where the depth lives

This domain teaches the browser-side mechanism and hands the rest off.

Securityxss
Domains that do not exist yet
  • Programming Languages & Runtime Internals — closures are the mechanism behind the stale-value bug: a function object holding the environment of the call that created it, which is exactly what a re-running render produces.