How Rendering Works
“Rendering” is one of the most misunderstood words in React. It does not mean “updating the screen” — it means React calling your component function to figure out what the UI should look like. Whether that result actually touches the DOM is a separate decision React makes afterward. Understanding the difference between a render and a DOM update is the key to reasoning about performance, effects, and why a component re-runs more often than you expected.
What triggers a render
A render is React invoking your component function to produce a new description of the UI (a tree of React elements). The very first render is the initial mount; every render after that is a re-render. There are exactly four things that cause a component to render:
| Trigger | What happens |
|---|---|
| Initial mount | React calls the component for the first time when it enters the tree |
| State change | A useState/useReducer setter is called with a new value |
| Props change | The parent passes new props during its own re-render |
| Context change | A consumed Context.Provider value changes |
There is a crucial implication of the third row: when a parent re-renders, its children re-render by default — even if their props did not change. React does not diff props to decide whether to call a child; it calls the child and then decides whether the result changes the DOM.
import { useState } from "react";
function Child() {
console.log("Child rendered");
return <p>I have no props and no state.</p>;
}
function Parent() {
const [count, setCount] = useState(0);
return (
<div>
<button onClick={() => setCount(count + 1)}>Count: {count}</button>
<Child />
</div>
);
}
Output:
Child rendered // initial mount
Child rendered // logged again on every click, even though Child never changed
Child re-renders on each click purely because Parent re-rendered. This is normal and usually cheap — rendering is fast. It only becomes a problem for expensive subtrees, which is what React.memo and useMemo exist to address.
Tip: Calling a state setter with a value that is
Object.is-equal to the current value will bail out of the re-render for that component.setCount(count)whencountis already5does nothing.
The render phase
The render phase is pure and side-effect free. React walks the component tree, calls each function component, and collects the returned elements to build a new virtual tree. During this phase React must be able to call your component any number of times, pause it, or throw the result away — so your render logic must not mutate external state, perform fetches, or write to the DOM directly.
function PriceTag({ amount, currency }) {
// Pure computation during render — derived entirely from props.
const formatted = new Intl.NumberFormat("en-US", {
style: "currency",
currency,
}).format(amount);
return <span className="price">{formatted}</span>;
}
Once the new tree is built, React compares it against the previous tree (this diffing step is reconciliation) to work out the minimal set of changes. No DOM has been touched yet at this point.
The commit phase
The commit phase is where React applies the calculated changes to the actual DOM. This is the only phase that mutates the host environment — inserting, updating, and removing DOM nodes, updating refs, and then running effects. useLayoutEffect callbacks fire synchronously after the DOM mutation but before the browser paints; useEffect callbacks fire asynchronously after paint.
┌─────────────────────── RENDER PHASE ───────────────────────┐
trigger │ call components → build element tree → reconcile/diff │
(state, └────────────────────────────┬────────────────────────────────┘
props, │ (pure, no DOM, can be discarded)
context) ▼
┌─────────────────────── COMMIT PHASE ───────────────────────┐
│ mutate DOM → update refs → run layout effects → paint │
└────────────────────────────┬────────────────────────────────┘
▼
run passive effects (useEffect)
The takeaway: a render does not imply a DOM write. If the new tree is identical to the old one for a given node, React renders (calls the function) but commits nothing for that node. This is why you can see your component log on every keystroke while the browser’s paint flame chart stays empty — the work was thrown away during reconciliation.
Rendering is not updating the DOM
Conflating the two leads to bad mental models and premature optimization. Re-rendering a thousand components that produce the same output costs some JavaScript CPU time but zero DOM work. Conversely, a single component can cause an expensive layout thrash if it commits a large DOM change. Optimize the phase that is actually slow: use the React DevTools Profiler to see render counts and commit durations rather than guessing.
Best practices
- Treat render functions as pure: no fetches, subscriptions, timers, or DOM mutations during render — put those in effects.
- Expect children to re-render when a parent does; reach for
React.memoonly when a profiler shows an expensive subtree, not preemptively. - Compute derived values during render instead of storing them in state and syncing with effects.
- Use
useLayoutEffectonly when you must read or write layout before paint; preferuseEffectotherwise to avoid blocking the browser. - Don’t call state setters with unchanged values expecting an update — React bails out on
Object.is-equal values. - Profile commit duration, not just render counts; a frequent render with no commit is usually harmless.