What you'll learn: By the end of this lesson you will be able to describe the three steps React takes for every screen update (trigger, render, commit), say which code belongs in which step, predict the order in which components render and effects, layout effects and cleanups run, explain what
<StrictMode>changes in development, and write a deterministic test that asserts all of it under Testing Library and jsdom without claiming anything about a real browser.
A stage play has two kinds of work. Before the scene, the crew writes the plan on paper: who enters, what is on the table. The audience sees none of it, and a draft can be thrown away and rewritten. Then the stagehands change the set and the lights come up. Moving furniture while the plan is still being written makes the plan unreliable.
React splits every update the same way. The render phase is the paper plan: React calls your components to find out what the screen should look like. The commit phase is the stage change: React writes the differences to the DOM. The previous lesson, React Reconciliation and the Virtual DOM, covered how the new plan is compared with the old. This lesson covers the order of everything around that comparison, including the code you attach with effects.
The running example is a product page: a ProductPage parent with a counter and two children. The lab traces logging components, so every claim about order below comes from running React 19.3.1 (react.dev lists 19.3 as the latest version at the time of writing), not from reasoning.
The core idea
- Trigger. A render starts for one of two reasons: the first render of a root, or a state update in a component or one of its ancestors.
- Render. React calls your component functions. This is pure calculation: same inputs, same output, and no changing of things that existed before.
- Commit. React writes the needed changes to the DOM, sets refs, and runs layout effects. Passive effects (
useEffect) follow. - Paint. The browser paints. This is outside React, and the lab does not model it.
The documentation puts it as "Trigger, Render, Commit".
Figure 1. From trigger to effects
How to read this: effects are drawn after paint because that is the usual case when an update was not caused by an interaction. The documentation says React "may run your Effect before the browser paints" when it was, so read the last arrow as "typically".
Trigger and render
An app starts with createRoot and root.render(<App />). After that, only a state update (the set function from useState, for example) queues a render, for the component and, by default, everything below it.
"Rendering is React calling your components." On the first render React calls the root; on later ones it calls the component whose state changed, and the process "is recursive": components it returns are rendered next. A component must "mind its own business": no changing of variables or objects that existed before it ran. Local changes to values created during the render are fine. Side effects belong in event handlers and, as a last resort, in effects.
The render phase sees the old screen. The lab's DomProbe reads the DOM during render, in a layout effect and in an effect (reading the DOM in render is shown only to make the point, not as advice):
=== dom-moments
note: What the DOM shows during render, in a layout effect and in an effect (jsdom: no paint, so nothing is said about the screen).
1. render DomProbe (count=0): DOM shows (not in the DOM yet)
2. layout effect (count=0): DOM shows Count 0
3. effect (count=0): DOM shows Count 0
-- click
1. render DomProbe (count=1): DOM shows Count 0
2. layout effect (count=1): DOM shows Count 1
3. effect (count=1): DOM shows Count 1
After the click, during the render for count=1 the DOM still says Count 0. By the layout effect it says Count 1. That is the boundary between the phases, seen from the inside.
Commit
After rendering, React applies the changes. On the first render it uses appendChild() to put the created nodes on screen. On later renders it applies "the minimal necessary operations", and "React only changes the DOM nodes if there's a difference between renders." The lab checks this with a MutationObserver: a child that renders again with the same output causes no DOM mutation records, while a parent whose text changes does.
=== dom-writes
note: Rendering is not the same as writing to the DOM: count the DOM mutations a MutationObserver sees.
1. render LabelParent (clicks=1)
2. render Label
3. DOM mutation records after the click (output unchanged): 0
--
1. DOM mutation records after the click (button text and two list items change): 3
The 3 is what jsdom reported for this tree, not a contract; the useful fact is zero versus more than zero.
The commit also sets refs ("React sets ref.current during the commit") and schedules effects in two groups:
useLayoutEffect"fires before the browser repaints the screen". It is for measuring layout. The documentation warns it "can hurt performance", because its code and the state updates scheduled from it "block the browser from repainting".useEffectruns after the commit. If it was not caused by an interaction, React "will generally let the browser paint the updated screen first"; if it was, React "may run your Effect before the browser paints". Either way, "Effects only run on the client": not during server rendering.
Figure 2. One update, in order
How to read this: the Note marks where a real browser can paint. The lab has no browser, so its trace shows only the order of calls, which is what React defines.
Effects: setup, cleanup and dependencies
An effect has a setup function and optionally a cleanup it returns. From the useEffect documentation: "After every commit with changed dependencies, React will first run the cleanup function (if you provided it) with the old values, and then run your setup function with the new values." After the component is removed, the cleanup runs one last time. Dependencies are compared with Object.is: with [a, b] the effect runs after the first commit and after commits where one changed; with [] only after the first; with no array, after every commit.
Across components, React 17's changelog lists "Run useEffect cleanup functions asynchronously" and "Clean up all effects before running any next effects." The lab shows that across a whole tree.
Illustration 1, the minimal example: mount, update, unmount
Each lab component logs when it renders and when its layout effect and effect set up and clean up. Parent renders children A and B and has a button that adds one to count, a dependency of all three. These lines are copied from the lab's golden output.
=== mount
note: First render of Parent with two Child components, then the commit.
1. render Parent (count=0)
2. render A (count=0)
3. render B (count=0)
4. layout setup A (count=0)
5. layout setup B (count=0)
6. layout setup Parent (count=0)
7. effect setup A (count=0)
8. effect setup B (count=0)
9. effect setup Parent (count=0)
=== update
note: One click: Parent's state goes from 0 to 1. Only the lines after the click are shown.
1. render Parent (count=1)
2. render A (count=1)
3. render B (count=1)
4. layout cleanup A (count=0)
5. layout cleanup B (count=0)
6. layout cleanup Parent (count=0)
7. layout setup A (count=1)
8. layout setup B (count=1)
9. layout setup Parent (count=1)
10. effect cleanup A (count=0)
11. effect cleanup B (count=0)
12. effect cleanup Parent (count=0)
13. effect setup A (count=1)
14. effect setup B (count=1)
15. effect setup Parent (count=1)
=== unmount
note: The root is unmounted. Only the lines after unmount() are shown.
1. layout cleanup Parent (count=0)
2. layout cleanup A (count=0)
3. layout cleanup B (count=0)
4. effect cleanup Parent (count=0)
5. effect cleanup A (count=0)
6. effect cleanup B (count=0)
- Render is top-down, effects run children first. On mount React renders
Parent,A,B, then runs layout setups forA,B,Parent, then effect setups in the same order. - On an update, all cleanups run before any setup, layout effects first, then effects.
- On unmount, cleanups run parent first.
The documentation read for this lesson does not state the order of effects across components. It is observed on React 19.3.1; do not write code that depends on it.
Illustration 2, the realistic example: render is not write
Clicking "Add" on the product page renders ProductPage and, by default, everything below it, changed or not. React.memo lets React skip a child whose props are unchanged. In the lab's memo scenario a parent renders a memoised child and a plain child with the same props every time:
=== memo
note: MemoParent re-renders on click. Memo (wrapped in React.memo) gets the same props; Plain is not wrapped.
1. render MemoParent (clicks=1)
2. render Plain (count=0)
The memoised child is not called; the plain one is, and neither writes to the DOM. Whether skipping is worth it is a question for the profiler. The model to keep is "render means call, commit means write".
Illustration 3, the failure case: StrictMode
<StrictMode> "enables extra development-only checks", and "All of these checks are development-only and do not impact the production build." In React 19 development:
- Component functions are called twice. "Strict Mode always calls your rendering function twice", to expose impure code. (DevTools dims the second call's logs; the lab records both.)
- Every effect gets one extra setup, cleanup, setup cycle, a stress test that checks that your cleanup logic "mirrors" your setup logic.
- Callback refs get an extra attach, cleanup, attach cycle on mount. The React 19 upgrade guide says this simulates "what happens when a mounted component is replaced by a Suspense fallback", and that
useMemoanduseCallbackreuse the first render's results during the second render.
The mount, wrapped in <StrictMode>:
=== strict-mount
note: The same mount as above, wrapped in <StrictMode> (development behaviour only).
1. render Parent (count=0)
2. render Parent (count=0)
3. render A (count=0)
4. render A (count=0)
5. render B (count=0)
6. render B (count=0)
7. layout setup A (count=0)
8. layout setup B (count=0)
9. layout setup Parent (count=0)
10. effect setup A (count=0)
11. effect setup B (count=0)
12. effect setup Parent (count=0)
13. layout cleanup Parent (count=0)
14. layout cleanup A (count=0)
15. layout cleanup B (count=0)
16. effect cleanup Parent (count=0)
17. effect cleanup A (count=0)
18. effect cleanup B (count=0)
19. layout setup A (count=0)
20. layout setup B (count=0)
21. layout setup Parent (count=0)
22. effect setup A (count=0)
23. effect setup B (count=0)
24. effect setup Parent (count=0)
Every component renders twice, all setups run, everything is cleaned up (parent first), and everything sets up again. An update has no extra cycle: the lab's strict-update scenario shows double rendering with one cleanup and setup per effect. The documentation says the right question is "how to fix my Effect so that it works after remounting".
Figure 3. What belongs where
How to read this: pick by purpose. Most side effects belong in event handlers; the documentation calls an effect a last resort when no event fits.
A state update inside a layout effect re-renders before paint. The documentation: "If you trigger a state update inside useLayoutEffect, React will execute all remaining Effects immediately including useEffect." The lab's Measured sets height from 0 to 10 in a layout effect:
=== layout-effect-set-state
note: A layout effect sets state while mounting.
1. render Measured (height=0)
2. layout effect (height=0)
3. effect (height=0)
4. render Measured (height=10)
5. layout effect (height=10)
6. effect (height=10)
The pending effect for height=0 ran before the second render began.
Build it: trace the phases yourself
Goal. See the order of render, layout effect, effect and cleanup calls as a text trace, with and without <StrictMode>, and check it with tests.
Setup. The lab is examples/frontend/labs/react-render-commit-order. It needs Node.js 22.18 or newer (captured on Node 26.12.1) and the workspace packages (React 19.3.1, Testing Library, jsdom). jsdom is a model of the DOM, not a browser.
cd examples/frontend/labs/react-render-commit-order
../../node_modules/.bin/vitest run # 22 tests
node src/demo.ts # prints every trace and the identity facts
Steps.
- Write down the order you expect for the mount trace, then compare it with the output above.
- In
src/app.ts, find the effects ofChild: which dependencies make them re-run? - Run the tests.
ordering.test.tsxasserts each trace line by line, and one test checks that Testing Library and a plaincreateRootplusact()give the same trace. The documentation says Testing Library's helpers are "wrapped withact()", which flushes updates and effects before you assert.
Break it. In MemoParent, change count: 0 for the memoised child to count: clicks. The memo test fails: the child now gets new props on every click, so React calls it again.
Stretch. Add a child with an empty dependency array and write down when its setup and cleanup run on mount, update and unmount, with and without Strict Mode.
jsdom does no layout and no painting. Nothing here says when a real browser paints relative to an effect, or how long anything takes.
In practice: a reviews panel that measures itself and subscribes to a feed
This is a realistic scenario, not a case history. It is a composite built from common production patterns. It describes no specific company or product, and every number in it is an illustrative assumption.
The situation
A product page shows a Reviews panel that must know its height, so a sticky "Add to cart" bar can sit below it, and must follow a live feed ("3 new reviews"). The panel is used on about 40 product templates (illustrative).
Starting architecture
Figure 4. The panel and its two effects
How to read this: the warning box is the effect that talks to something outside React. That is where a missing cleanup turns into a leak.
Options considered
| Option | What it fixes | What it costs | Risk |
|---|---|---|---|
Measure in useEffect |
Simple | The first paint may show the wrong position first | A visible shift; the documentation shows the same flicker for a tooltip |
Measure in useLayoutEffect |
Correct before paint | Blocks painting while it runs | Slow work in it delays the frame |
Subscribe in useEffect with a cleanup |
Subscriptions follow mounts | A cleanup to write | None if the cleanup mirrors the setup |
The decision
| Field | Decision record |
|---|---|
| Context | The panel must know its height before the user sees it, and follow a live feed while it is on screen. |
| Decision | Measure in useLayoutEffect (read the height only); subscribe in useEffect and return a cleanup that unsubscribes. |
| Consequences, good | Correct position on the first visible frame; subscriptions follow the panel's lifetime. |
| Consequences, bad | The layout effect blocks painting while it runs; two effects to review. |
| Revisit when | Profiling puts the layout effect on an interaction's critical path, or CSS can give the height. |
Rollout and verification
- Write the order test first: under
<StrictMode>, mount the panel with a fake feed and assert exactly one active subscription (setup, cleanup, setup leaves one); afterunmount(), zero. - Run development with Strict Mode on and watch for double setups with no cleanup between.
- Check on a real device for position and paint behaviour, which jsdom cannot show.
The 2 a.m. failure
The panel ships without the cleanup: the engineer saw duplicate "subscribed" lines in development, took them for Strict Mode noise, and moved on. In production Strict Mode's extra cycle does not run, so the first load looks fine. Support then reports that customers who browse from product to product see the "3 new reviews" toast two, then three, then four times. Each visit mounts the panel and subscribes again, and nothing ever unsubscribes.
First three checks: (1) in development, look for effects whose setup runs twice with no cleanup between; (2) count active feed subscriptions after navigating through three products; (3) read the effect: does it return a function that undoes its setup? Mitigation: deploy the cleanup. Follow-up: keep the order test, and make "a duplicated development log is a bug" a team rule.
Trade-offs: where to run what
| Place | Runs | Good for | Watch out for |
|---|---|---|---|
| Render | Whenever React calls the component, twice in Strict Mode | Calculating what to show | Any side effect |
| Event handler | When the user acts | Reacting to a click or input | Nothing React-specific; the default home for side effects |
useLayoutEffect |
After the commit, before paint | Measuring and correcting layout | Blocks painting; not run on the server |
useEffect |
After the commit, usually after paint | Syncing with systems outside React | Missing cleanup, stale dependencies |
Architect's notes
- Make every effect reversible. If cleanup does not undo setup, Strict Mode shows it early; production shows it expensively.
- Keep render pure and boring. It is the one phase React may call more than once.
- Reserve
useLayoutEffectfor measurements that must happen before the user sees the frame. - Do not depend on effect order across components. It is observed, not documented.
Common mistakes
- Treating a re-render as a DOM update, or putting side effects in the render body.
- Writing effects without cleanups for subscriptions, timers or listeners.
- Guarding an effect with a ref to run it once. The documentation warns against it.
- Reaching for
useLayoutEffectby default, or assuming effects run on the server.
Key takeaways
- Every update is trigger, render, commit; the browser paints afterwards.
- Render calls components and must be pure; commit writes the differences to the DOM, only where renders differ.
- Refs are set in the commit; layout effects run before paint; effects usually run after paint.
- On an update React runs all cleanups before any setup; dependencies are compared with
Object.is. - StrictMode (development only) renders twice and adds an extra effect and ref cycle to expose impurity and missing cleanups.
- The order of effects across components is observed, not documented.
- A state update inside a layout effect re-renders before paint, after the pending effects run.
- Testing Library under jsdom checks call order, not layout or paint.
Practice exercises
Exercise 1 — Starter (30 min): Predict, then check against the lab, the lines for a parent with three memoised children when it re-renders and only the middle child's props change.
Exercise 2 — Intermediate (90 min): Write an effect that subscribes to a fake emitter and returns the cleanup. Test, under <StrictMode>, that exactly one subscription is active after mount and zero after unmount.
Exercise 3 — Advanced (half day): Add a component whose layout effect sets state and one whose effect sets state. Record both traces and note which part of the difference jsdom cannot show.
Related reading
In this course:
- React Reconciliation and the Virtual DOM, how React compares the new tree with the old and what keeps identity.
- The Event Loop and the Main Thread, why a long render or layout effect delays input and paint.
- The Critical Rendering Path, what the browser does after the DOM changes.
Official documentation
- React: Render and Commit, the three steps.
- React: useEffect and useLayoutEffect, timing, cleanup and dependencies.
- React: StrictMode and the React 19 upgrade guide, development-only checks.
- React: act, flushing updates in tests.
- React v17 release notes, the effect cleanup changes.