Skip to content

Render and commit

Rendering is React calling your components to compute a tree. Committing is the only step that touches the DOM, and it applies only differences.

Predict first

Before you read: predict

EXH-363, 09:04, ECON-201. A proctor reports that candidates on older Chromebooks see the question panel stutter once a second. In the React DevTools Profiler, one commit fires every second while the exam timer counts down.

Development build, no StrictMode. ExamPage holds secondsLeft in useState. It renders <TimerBadge secondsLeft={secondsLeft} /> and 60 <QuestionPane q={q} />. Each q is a constant object that never changes, and QuestionPane is not wrapped in memo. The timer ticks once, from 90:00 to 89:59.

How many times does the QuestionPane function run, and how many DOM text nodes change?

Pick one

The 30-second version

A render is triggered by a component's own state update, its parent rendering, or a context it reads. Rendering calls that component and every unmemoized descendant to compute a new element tree. It must be pure, and React may discard it. Commit changes only the DOM nodes whose output differs, runs layout effects, and then effects run.

Rendering is React calling your components to compute a tree. Committing is the only step that touches the DOM, and it applies only differences.

How it works

Step 1 of 7

Trigger. Its own setState, a parent render, or a read-context change queues a render. An Object.is-equal setState may call it once, skipping its children.

Analogy: An architecture practice and its site crew

In the analogy In React
A client's change request reaches the practice A trigger (setState, a parent render, context)
Revising one sheet obliges redrawing every sheet bound beneath it, unless a sheet is stamped "reuse if its inputs are unchanged" A parent render re-renders its children, unless they are memo with equal props
Drawings are paper only. A draft can be binned and never reach the site Render is pure and may be discarded
The crew lays the new set over the previous set and builds only what differs Commit diffs the new tree against the previous one and writes only the differences
The snagging inspection happens before the client is let in Layout effects run before paint
Utility hookups are booked after handover useEffect runs after commit

Breaks down when:

  • A crew could inspect the building, but React diffs drawings only. It misses manual DOM edits, so "React will fix my DOM edit" is wrong.
  • Redrawing sounds costly, but a render is a function call: CPU time, not DOM work.

Contrasting case: a sign painter who repaints the lobby sign on every change, like imperative el.textContent = … per tick. There is no draft and no diff, so every write reaches the DOM.

In real terms: render computes a new element tree, and commit reconciles it against the previous tree and mutates only the DOM nodes that changed.

Examples, run for real

E1 · ExamHall: the timer tick re-renders the whole question panel (EXH-363)

function Badge({ secondsLeft }) { c.badge++; return h('span', { id: 'timer' }, fmt(secondsLeft)); }
const Pane = ({ q }) => { c.pane++; return h('section', { className: 'pane' }, q.text); };
function ExamPage() {
  c.page++;
  const [s, setS] = useState(5400); tick = () => setS((x) => x - 1);
  return h('main', null, h(Badge, { secondsLeft: s }), questions.map((q) => h(Pane, { key: q.id, q })));
}

Per tick, development build (the second number in each cell is StrictMode on):

Variant ExamPage TimerBadge QuestionPane DOM writes
baseline (above) 1 / 2 1 / 2 60 / 120 text "90:00"->"89:59"
memo(Pane) 1 / 2 1 / 2 0 / 0 same single text write
memo(Pane) but q rebuilt with { ...q } each render 1 / 2 1 / 2 60 / 120 same single text write
state moved down into TimerBadge (MR !658) 0 / 0 1 / 2 0 / 0 same single text write

Renders range from 0 to 120, but the DOM work is always one text node.

E2 · ExamHall: the order inside a commit

TimerBadge logs its render, a useLayoutEffect that reads the DOM and books a requestAnimationFrame, and a useEffect. Update log (after [update: …]):

click, StrictMode off:  render 89:59 > layout cleanup > layout effect (DOM: 89:59) > effect cleanup > effect (DOM: 89:59) > MutationObserver 1 rec > rAF
click, StrictMode on:   render 89:59 > render 89:59 > layout cleanup > layout effect (DOM: 89:59) > effect cleanup > effect (DOM: 89:59) > MutationObserver 1 rec > rAF
setTimeout, off, either: (setState returned) > render 89:59 > layout cleanup > layout effect (DOM: 89:59) > MutationObserver 1 rec > effect cleanup > effect (DOM: 89:59) > rAF
                     or: (setState returned) > render 89:59 > layout cleanup > layout effect (DOM: 89:59) > MutationObserver 1 rec > rAF > effect cleanup > effect (DOM: 89:59)

The layout effect always sees the new DOM. After a setTimeout trigger, useEffect and the next frame (rAF) come in either order; which one comes first varies by build and load.

E3 · Proof domain, chat: re-render ≠ DOM change

Thread owns 20 messages. MessageRow is not memoized.

StrictMode=off | poll, equal copies: Thread 1, MessageRow 20, DOM none | same state (prev => prev): Thread 1, MessageRow 0, DOM none | same state again: Thread 0, MessageRow 0, DOM none | one new message: Thread 1, MessageRow 21, DOM childList +1/-0
StrictMode=on  | poll, equal copies: Thread 2, MessageRow 40, DOM none | same state (prev => prev): Thread 2, MessageRow 0, DOM none | same state again: Thread 0, MessageRow 0, DOM none | one new message: Thread 2, MessageRow 42, DOM childList +1/-0

Equal copies cost 20 renders and zero DOM writes. The first same-state update still called Thread once, then skipped its children; the second bailed out completely.

Picture it

an architect drafting a building on a loose sheet while a finished model stands untouched beside it. Draft = render, model = committed DOM.
An illustration to help it stick. The mechanism is in the sections above.

Try it

Answer first, then check. Nothing is saved.

Question 1 of 8

TicketBoard keeps filter in state and shows it in an <h2>. It renders 30 memo(TicketRow) with onSelect={(id) => setSelected(id)}. The filter changes from "" to "db".

How many TicketRow renders happen, and how many DOM text nodes change?

Pick one

Question 2 of 8

Same board.

What one change to TicketBoard makes the filter change cause 0 TicketRow renders?

Question 3 of 8

A socket message (a setTimeout task) switches the now-playing bar from Track 1 to Track 2.

Order these chips: render Track 2 · useEffect runs · useLayoutEffect runs.

Your order

  1. 1empty
  2. 2empty
  3. 3empty

Tap the lines in the order you expect.

Question 4 of 8

In that update, which text does the useLayoutEffect read from the bar's DOM node?

Pick one

Question 5 of 8

A cart badge's count lives in CatalogPage state. Adding an item makes the Profiler show 200 ProductCard renders, but a MutationObserver sees one text write.

Where does the time go?

Question 6 of 8

Same page.

Name one change that stops the 200 ProductCard renders.

Question 7 of 8

Development build, no StrictMode. CONTACTS is a module-level array of 30 contacts that never changes.

One message from u7 arrives. The Profiler shows all 30 ContactRow renders, though only u7's count changed.

Which part stops memo from skipping the other 29 rows?

Code for this question
const UnreadContext = createContext({});function useUnread(id) { return useContext(UnreadContext)[id] ?? 0; }const ContactRow = memo(function ContactRow({ contact, onOpen }) {  const unread = useUnread(contact.id);  return <li onClick={() => onOpen(contact.id)}>{contact.name} ({unread})</li>;});function ChatApp() {  const [counts, setCounts] = useState({});   // a message from u7 runs setCounts((c) => ({ ...c, u7: (c.u7 ?? 0) + 1 }))  const openChat = useCallback((id) => navigate(id), []);  return (    <UnreadContext.Provider value={counts}>      {CONTACTS.map((c) => <ContactRow key={c.id} contact={c} onOpen={openChat} />)}    </UnreadContext.Provider>  );}
Pick one

Question 8 of 8

Development build, no StrictMode. A live stream's status badge:

A viewer clicks Pause, then Resume, and the badge still says "paused".

Which single change makes Resume show "live" again?

Code for this question
function StreamStatus() {  const [status, setStatus] = useState('live');  const badge = useRef(null);  function onPause() { badge.current.textContent = 'paused'; }  function onResume() { setStatus('live'); }  return (<>    <b ref={badge}>{status}</b>    <button onClick={onPause}>Pause</button>    <button onClick={onResume}>Resume</button>  </>);}
Pick one

Go deeper

Pro tips
  1. Count renders against DOM writes (RAN). In the console, run new MutationObserver(r => console.log(r.length)).observe(document.body, {subtree:true, childList:true, characterData:true, attributes:true}), then trigger the update. React DevTools' Profiler shows the render count for the same interaction. Serves steps 2 and 4. Verify: on the EXH-363 page, one tick logs 1 while the Profiler lists 60 QuestionPane renders. Pins: Chromium 153, React 19.3.0.
  2. Read the phases in React Performance Tracks (DOC, RDT-01). In a development build, record in Chrome's Performance panel. The Scheduler track splits each update into Update → Render → Commit → Remaining Effects. Serves steps 5–6. Verify: record one timer tick and check that the tick's Commit block precedes its Remaining Effects block. The source is react.dev/reference/dev-tools/react-performance-tracks, fetched 2026-09-27. Status: DOC, UI not executed here.
Source
  • react.dev: Render and Commit. Fetched 2026-09-27; the page exists. It covers trigger → render → commit, the recursive rendering of children, and "React only changes the DOM nodes if there's a difference between renders." Valid as of 2026-09-27.
  • react.dev: <StrictMode>. Fetched 2026-09-27; the page exists. It covers double rendering, the extra setup and cleanup on mount, and that the checks are development-only. Valid as of 2026-09-27.
  • Supporting reference for step 6: react.dev: useEffect caveats. An interaction-caused Effect may run before paint; otherwise React "will generally" let the browser paint first. Valid as of 2026-09-27.

Checked against the tools and docs as of 2026-09-27.