What you'll learn: By the end of this lesson you will be able to explain what a React "render" is and is not, predict which components render after a state update, say what
React.memo,useMemoanduseCallbackcompare and cache, recognise the prop shapes that silently defeat them, and prove your answer by counting renders in a test. The React Compiler is mentioned only as react.dev documents it.
Picture a school secretary who, whenever one pupil hands in a corrected form, reprints the whole class's forms "just in case" and checks each against the filing cabinet. Most match, so the cabinet does not change, but the work was done.
React behaves a little like that. When a component's state changes, React calls it again and, by default, every component below it. The tools in this lesson say "this form has not changed, skip it". Each has a cost, and on the wrong form each saves nothing.
The running example is a catalogue page with a search box, a product list, a cart badge and a sort button. Typing updates state at the top of the page, and the question is "which other components have to run again?" The lab installs React 19.3.1; react.dev's versions page listed 19.3 as the latest release when this was written.
The core idea
- A render is React calling your component function. It calculates what the screen should look like. The DOM changes later, in a separate commit step, and only where the output differs.
- A parent render renders its children by default. React does not first ask whether a child's props changed.
memo,useMemoanduseCallbackare caches keyed byObject.is. They save work only when what they compare is the same value as last time. A new object, array or function is never the same value, even with the same contents.
React's own pages state the purpose: these are performance optimizations, and "if your code doesn't work without it, find the underlying problem and fix it first" (react.dev, read 2026-11-08). They must never decide whether your program is correct.
What a render is, and what it is not
React's guide describes a screen update as three steps: trigger, render, commit. A state update in a component or one of its ancestors triggers a render. "Rendering" is "React calling your components", recursively down the tree. The commit step then changes the DOM, applying "the minimal necessary operations". The guide's recap: React does not touch the DOM if the rendering result is the same as last time.
So there are two costs. Render cost is your functions running, which memoization can skip. Commit cost is DOM changes, which an unchanged output avoids anyway. "This component re-rendered" means a function ran. Whether that matters is a question for a profiler; a render counter cannot answer it, and this lesson makes no claim about how long any render takes.
Figure 1. A state update in a parent, and what memo changes
How to read this: the update starts at the parent and flows down. The plain child is called because its parent was. The memo child is called only if a prop is not Object.is-equal to its previous value. Rendered output goes to the commit step, which changes the DOM only where it differs.
What memo does and what it compares
memo(Component) returns a memoized version. React's reference says it lets you "skip re-rendering a component when its props are unchanged", comparing each prop with Object.is (Object.is(3, 3) is true, Object.is({}, {}) is false). It also says React "may still re-render it: memoization is a performance optimization, not a guarantee."
Figure 2. The check memo performs
or context changed?"} B -->|"yes"| R["Child renders"] B -->|"no"| C{"Every prop equal
by Object.is?"} C -->|"yes"| S["Render skipped"] C -->|"no"| R
How to read this: props are only one of three reasons a memoized component renders. Its own state and any context it reads bypass the props check, so memo does nothing for those updates.
Illustration 1: the minimal example. The lab's first scenario has a plain badge, a memoized badge, and a button that updates the parent's state:
export function PlainBadge() {
renderLog.record("PlainBadge");
return <span>badge</span>;
}
export const MemoBadge = memo(function MemoBadge() {
renderLog.record("MemoBadge");
return <span>badge</span>;
});
renderLog.record counts each call React makes. After mount and three clicks, the test asserts PlainBadge ran 4 times and MemoBadge once. Neither has props, so only the memoized one can skip.
Prop shapes decide whether memo works
React's reference warns that memo is "completely useless if the props passed to your component are always different", such as an object or function defined during rendering; one always-new prop is enough to break it. Each row below is one test in the lab:
| Prop passed to the memoized child | Renders on an unrelated parent update? | Why |
|---|---|---|
filter={{ tab }} (object literal) |
Yes, every time | A new object each render |
filter={memoizedFilter} (useMemo on [tab]) |
No | Same object until tab changes |
tab={tab} (a string) |
No | Primitives compare by value |
onPress={() => ...} (inline function) |
Yes, every time | A new function each render |
onPress={cachedCallback} (useCallback) |
No | Same function until dependencies change |
<b>note</b> as children |
Yes, every time | JSX creates a new element each render |
The primitive row matters most: React's reference prefers passing "the minimum necessary values" over useMemo for an object. Pass the string, not the object around it.
const memoizedFilter = useMemo<Filter>(() => ({ tab }), [tab]);
return (
<section>
{mode === "inline-object" && <FilterChip filter={{ tab }} />}
{mode === "memoized-object" && <FilterChip filter={memoizedFilter} />}
{mode === "primitive" && <TabChip tab={tab} />}
</section>
);
useMemo: two different jobs
useMemo(calculateValue, dependencies) runs a pure function on the first render and later returns the stored result unless a dependency changed by Object.is. React's reference says it is "only valuable" when (1) the calculation is noticeably slow and its dependencies rarely change, (2) the result is a prop of a memo component, or (3) the value is a dependency of another hook. Otherwise "there is no benefit". It does not speed up the first render. To judge "slow", the page suggests console.time in a production build with CPU throttling.
The jobs differ. Job 1 saves a calculation. Jobs 2 and 3 are about identity: keeping an object the same object so something downstream can compare it. A useMemo that is only rendered, with no slow calculation, has no job.
Illustration 2: the realistic example. On the catalogue page, VisibleProducts filters by the search text; the lab wraps it in memo and caches the filter with useMemo on [products, query]. After two unrelated parent updates:
| Parent passes | VisibleProducts renders |
Filter runs |
|---|---|---|
| The same array each time | 1 | 1 |
A fresh copy each time (products.map((p) => p)) |
3 | 3 |
With a fresh array both tools do nothing: it is a new prop and a new dependency. More hooks do not fix a root cause in the parent.
useCallback: caching a function's identity
useCallback(fn, dependencies) returns the stored function while dependencies are unchanged. React describes it as roughly useMemo(() => fn, dependencies): useMemo caches a result, useCallback caches the function and never calls it. It pays off in the same identity cases.
Figure 3. Two renders of a parent, with an inline callback and a cached one
How to read this: the same parent update gives different outcomes depending only on whether the function kept its identity.
Each changing dependency produces a new function, so keep dependencies few. In the lab, saveCached reads the draft and must depend on it; clearCached only calls a state setter, so its list is empty:
const saveCached = useCallback(() => {
saved.saved.push(draft);
}, [draft, saved]);
const clearCached = useCallback(() => {
setDraft("");
}, []);
After two keystrokes the Save button had rendered 3 times and Clear once. React's reference recommends "as few dependencies as possible" and an updater function (setItems((items) => [...items, newItem])) so state itself is not a dependency.
When none of this helps
Illustration 3: a failure and an edge case.
A custom comparison that forgets a function. memo accepts a comparison function as a second argument, and React's reference says you "must compare every prop, including functions". The lab's PickList compares id and label but not onPick. After two "Next step" clicks, clicking the row runs the handler from step 0, because the row was never re-rendered with the newer function. The test asserts ["a@step0"]; comparing all props gives ["a@step2"]. That is a stale closure, and memo caused it.
Strict Mode doubles your counter. React's Strict Mode reference says that in development it calls component bodies, and functions passed to useMemo, useState and useReducer, an extra time to find impure code, and that this is development-only. In the lab's Strict Mode test the counts are exactly doubled: 2 after mount, 4 after one update. Count in the same mode every time.
The React Compiler, as documented
react.dev's introduction says React Compiler is a build-time tool that "automatically optimizes your React app", reducing the need to memoize by hand. It says useMemo and useCallback remain available as an escape hatch (for example when a memoized value is an effect dependency), that for existing code it recommends leaving memoization in place or testing carefully before removing it, and that the compiler is "still an optional addition" at the time of writing. The lab runs without the compiler, so its counts describe manual memoization only.
Trade-offs
| Technique | Saves | Costs | Use when |
|---|---|---|---|
| Pass primitives or fewer props | A class of new-object props | A different component interface | Nearly always first |
memo |
A child render when props are equal | A comparison each parent render; useless if any prop is always new | Renders often with the same props and is expensive |
useMemo (calculation) |
The calculation on updates | A dependency list and a cache | Measurably slow, inputs change rarely |
useMemo / useCallback (identity) |
A new object or function per render | The dependency list; stale values if wrong | The value feeds a memo child or a hook dependency |
| Custom comparison | Skips when differences do not matter | Stale UI if a prop is forgotten | Rarely, and compare every prop |
| Move state down | Whole subtrees from the render path | A restructure | The slow part does not need the changing state |
Build it: count the renders
Goal. Predict, then verify, which components render after a state update, and watch each shortcut help and fail.
Setup. The lab is examples/frontend/labs/react-memoization-renders. It uses React 19.3.1 and Testing Library under jsdom, a JavaScript model of the DOM that is not a browser: no layout, no painting, no timing. The lab measures no time at all.
cd examples/frontend
pnpm install
cd labs/react-memoization-renders
pnpm test # 17 tests
pnpm typecheck
Steps.
- Read
src/scenarios.tsxbesidetest/memoization.test.tsx. Before eachdescribeblock's assertions, write the counts you expect. - Run the tests. All 17 passed in the run made while writing this lesson (React 19.3.1, vitest 5.1.0, jsdom 31.0.0).
- Read the Strict Mode test and work out why the counts are exactly doubled.
Break it. In PropShapes, change the useMemo dependency list to [tab, {}]. The fresh {} is a new dependency every render, so the "memoized object" test fails (confirmed by running it). Revert it.
Stretch. Add a context to MemoBadge and show it still renders when the context changes. The lab has no demo script or golden output: the test assertions are the output, because Node cannot run a .tsx file directly.
In practice: a catalogue page where typing feels heavy
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 catalogue page has a search box, a grid of several hundred product cards (illustrative), a cart badge and a sort dropdown. All state lives in the top component, so each keystroke updates it. Reviewers on slower phones report that typing lags.
Starting architecture
Figure 4. Every card hangs below the component that owns the search text
How to read this: no single component is wrong. The cost comes from where the state lives, which puts the whole grid on the render path of every keystroke.
Options considered
| Option | What it fixes | Risk |
|---|---|---|
memo every component |
Skips cards with equal props | Inline objects and functions defeat it silently |
useMemo the filtered list |
Filtering on unrelated updates | Cards still render if the parent renders |
| Keep the query in the search box and pass a debounced copy to the grid | Header and sort leave the render path | Results trail typing slightly |
Memoize the card, pass primitives and a useCallback handler |
Only changed cards render | One new object prop brings the cost back |
The decision
| Field | Decision record |
|---|---|
| Context | One state owner renders a large grid per keystroke; the query affects only the grid. |
| Decision | Local query state in the search box, a debounced copy for the grid, a memoized card taking primitives, and useMemo on the filtered list because it feeds the memoized grid. |
| Consequences, good | Header and sort stop rendering per keystroke; unchanged cards skip; render-count tests state the intent. |
| Consequences, bad | Results appear slightly after typing; the card's props are now a contract that one inline object can break. |
| Revisit when | Field data still shows slow interactions, or the compiler is adopted and the manual hooks are re-evaluated against its documentation. |
Rollout and verification
- Write the counting test first: type three letters, assert the header rendered once and unchanged cards once.
- Profile before and after on a production build with CPU throttling; counts show work was skipped, not how long it took.
- Ship behind a flag and compare interaction metrics for flagged and unflagged traffic, and test awkward paths such as clearing the box mid-query.
The 2 a.m. failure
Support reports that "Add to cart" sometimes adds the previous quantity. A custom comparison, added to make cards skip more often, compares id, name and price but not the onAdd handler, which closes over the selected quantity. After the quantity changes, the card keeps the old function.
First three checks: (1) reproduce by changing the quantity, then clicking add; (2) list every memo(..., comparator) and the props each ignores; (3) diff the stale card's props against the parent's latest. Mitigation: remove the comparator so React compares every prop. Follow-up: a test that the handler reflects the latest quantity, and a review rule that comparisons cover every prop, functions included.
Architect's notes
- Fix the shape before adding a hook. Moving state down or passing a primitive removes the re-render without a cache to maintain.
- Treat memoization as a contract. Document which props must be stable and test it.
- Measure time in a profiler, count renders in a test. Never report a render count as a speed-up.
- Memoize at boundaries, between an expensive subtree and a frequently updating parent, not among cheap siblings.
Common mistakes
- Wrapping a component in
memoand passing it an inline object, array or function. - Using
useMemoon a cheap calculation with nomemochild or hook dependency downstream. - Dropping a dependency from
useCallbackoruseMemoto keep identity stable. - Writing a custom comparison that skips a function prop.
- Relying on memoization for correctness; React may re-render and may discard cached values.
- Comparing render counts between development (Strict Mode) and production builds.
Key takeaways
- A render is React calling your component; the DOM changes in the later commit, only where output differs.
- A parent render renders its children;
memoskips a child when every prop isObject.is-equal. memodoes not skip on the child's own state change or a context change.- New objects, arrays, functions and JSX elements defeat
memo; primitives and cached values do not. useMemocaches a result anduseCallbacka function, both keyed on dependencies, both only optimizations.- They are valuable for slow calculations, props to memoized children and hook dependencies.
- A custom comparison must compare every prop, or the child can hold a stale closure.
- Strict Mode doubles component calls in development, so read counters in one mode.
- Render counts prove work was skipped; only a profiler says how long the work took.
Practice exercises
Exercise 1 - Starter (30 min): Build a parent with a button and one memoized and one plain child, each with a render counter. Predict the counts after five clicks, then pass the memoized child style={{ color: "red" }} and explain the new count.
Exercise 2 - Intermediate (2 hours): Add an Undo button to the lab's Composer whose handler reads the draft. Choose its dependencies and write a test for when it re-renders.
Exercise 3 - Advanced (half day): Take a component tree from your own project, draw which components render for the most common update, then count with a render counter and compare.
Related reading
In this course:
- The Event Loop and the Main Thread: why a long render delays input.
- Rendering Strategies: where React's render runs, on the server and in the browser.
- Core Web Vitals: the metrics that show whether a render change helped.
The prerequisite lesson on how hooks work inside React, and the next lesson, Context and Its Render Cost, are planned and have no page yet.
Official documentation
All pages read on 2026-11-08.
- React:
memo, the comparison, what still renders, and custom comparison functions. - React:
useMemoanduseCallback, caching, dependencies and when they are valuable. - React: Render and Commit, the three steps.
- React:
StrictMode, the development-only extra render. - React Compiler: Introduction, and React versions.