Skip to content
BloGrove
web dev

The React Rendering Model, Explained Once and Properly

How React decides what to re-render — components, state triggers, reconciliation, keys — and a mental model that avoids perf bugs and over-memoization.

BBloGrove Editorial3 min read
The React Rendering Model, Explained Once and Properly

React interviews and real-world performance work share one foundation: understanding what happens when state changes. Most React confusion — stale closures, infinite effects, pointless useMemo — dissolves once the rendering model clicks. Here it is, end to end: trigger → render → reconcile → commit.

Components are functions that return UI descriptions#

A component call doesn't produce DOM; it produces a description of desired UI:

function PriceTag({ price }) {
  return <span className="price">${price.toFixed(2)}</span>;
}

React stores these descriptions as an element tree (the "virtual DOM") and compares each new render against the previous one, applying only the differences to the real DOM. That comparison process is reconciliation, and it's why you write declarative UI ("what should exist") while React handles imperative DOM surgery ("how to get there").

What triggers a render#

Exactly three things cause a component function to execute again:

  1. Its own state changes (useState setter called)
  2. Its parent renders (props may have changed)
  3. A context it consumes changes

The critical implication: a child re-renders whenever its parent does, even if nothing it uses changed. This is by design — React can't know cheaply whether props are meaningfully different (objects and functions fail === even when "equal"), so it errs toward rendering. Which means:

// Parent re-renders every second...
function Dashboard() {
  const [now, setNow] = useState(Date.now());
  // ...
  return <HeavyChart data={data} />;  // ...so this re-renders too
}

...and HeavyChart recomputes despite identical data. That's usually fine (rendering is cheaper than you fear), but it's also where all performance conversation starts.

State updates replace, never mutate#

// Wrong: same array reference, React sees "nothing changed"
items.push(newItem);
setItems(items);

// Right: new reference with new contents
setItems([...items, newItem]);

React detects change by reference comparison. Mutating in place hands it the identical object — no render, stale UI, mysterious bugs. Every state update must create new references for anything changed: spread for objects/arrays, .map()/.filter() instead of push/splice. This single rule explains half of all beginner React bugs.

Keys: how reconciliation identifies list items#

{todos.map(todo => <TodoRow key={todo.id} todo={todo} />)}

When lists reorder, React must know which element is which. Keys provide identity — and array index keys break under reordering: delete item 0 and every row's key shifts, so React patches mismatched state into wrong rows (the classic "deleted the wrong checkbox" bug). Stable, unique IDs from your data; indices only for append-only, never-reordered lists.

Also worth knowing: keys aren't just for lists — placing key={userId} on a whole subtree forces full remount when identity changes, resetting internal state deliberately.

Effects and the render boundary#

useEffect runs after commit, synchronizing with external systems (subscriptions, timers, non-React widgets) — not for deriving data:

// Anti-pattern: effect + state to compute what's already derivable
const [full, setFull] = useState('');
useEffect(() => setFull(first + ' ' + last), [first, last]);

// Correct: derive during render
const full = first + ' ' + last;

Derivable-during-render values don't need state or effects; keeping them out eliminates entire bug categories (stale syncs, extra renders, waterfall loops). The dependency array isn't bureaucracy — it declares exactly which changing values should re-run the effect, and lying about it produces the stale-closure bugs that give effects their bad reputation.

When to optimize (and when not)#

Reach for memo, useMemo, useCallback only when profiling shows actual cost:

  • Genuinely expensive computations (large transforms, big list filtering)
  • Subtrees proven slow by measurement, fed stable references
  • Context values consumed widely

Premature memoization adds complexity, hides bugs behind frozen references, and often costs more than it saves. The honest workflow: build plainly → measure → optimize the measured hot spots. Interview bonus points come from saying that, unprompted.

The model in one paragraph#

State changes mark components dirty → React calls those component functions for fresh descriptions → reconciles against the old tree using type-and-key matching → commits minimal DOM mutations → runs cleanup/effects. Everything else — hooks rules, memoization, suspense — is machinery around that loop. Hold the loop clearly and React stops feeling like magic and starts feeling like what it is: a very disciplined diff engine.

Related: Next.js App Router patterns · TypeScript for component contracts

Enjoyed this article?

Share it with your network.

Share

Keep reading

TypeScript Generics and Utility Types: The Practical Guide
web dev

TypeScript Generics and Utility Types: The Practical Guide

Generics and utility types through real refactors — when <T> earns its complexity, which built-ins matter, and the patterns codebases actually use.

3 min read
CSS Flexbox vs Grid: Choosing the Right Layout Engine
web dev

CSS Flexbox vs Grid: Choosing the Right Layout Engine

Deciding between Flexbox and Grid in practice — how their mental models differ, plus recipes for navbars, cards, split pages, and sticky footers.

4 min read