React in Depth
Performance: Memoization, Lists, Code Splitting and the React Compiler
Find slow renders with the Profiler, memo, virtualised lists, lazy loading, transitions, and what the React Compiler changes.
On this page
- Words used in this lesson
- Rule 0: measure first
- Why does a component render? Three reasons only
- Fix 1: move state down, or pass children
- Fix 2: memo, and why it fails so easily
- Fix 3: useMemo for slow calculations
- The Profiler in numbers
- Fix 4: long lists, virtualisation
- Fix 5: code splitting with lazy and Suspense
- Fix 6: transitions and useDeferredValue for slow updates you cannot make fast
- React Compiler: memoization that writes itself
- A simple checklist for a slow screen
- Try it
- Interview questions
- Key takeaways
- What's next
In one line: do not optimise React by guessing. Measure first (React DevTools Profiler, the <Profiler> component, or a render counter), find the component that renders too often or too slowly, and then pick the cheapest fix: move state down, pass children, memo with stable props, useMemo for a slow calculation, virtualise a long list, split code with lazy, or mark a slow update as a transition. And know what the React Compiler now does for you automatically.
"React is slow" is almost never true. What is true is that one screen renders too much, too often, or renders too many DOM nodes. This lesson gives you the tools to see which, and for each one a fix with before and after numbers that I measured.
This is lesson 7 of a ten-lesson series. It uses the render rules from State and Rendering (lesson 1) and the render counts from Context and State Management (lesson 6). The next lesson, Patterns, Server Components and Testing React, covers the structure side of a real app. For an interview-style view of the same ideas see React Rendering Model: Render, Commit, Reconciliation and Keys.
What I ran / checked. Linux, Node.js v22.22.0, react 19.3.0, react-dom 19.3.0, @testing-library/react 16.3.3, vitest 5.0.3 with jsdom, babel-plugin-react-compiler 1.0.0 with @babel/core 8.0.7, react-window 2.3.4, esbuild 0.28.2, @reduxjs/toolkit 2.13.0 (used only as a stand-in "heavy library" for code splitting), and Playwright with Chromium 141.0.7390.37 (/opt/pw-browsers), driven from the Playwright install in /opt/node22/lib/node_modules/playwright. Render counts are exact. Times are not: they come from a sandbox machine, they changed a lot from run to run, and I show ranges. Profiler numbers came from a development build of React inside jsdom, which is slower and different from a production build. The browser tests used a production build made with esbuild. Sources I read (own words): the React docs for memo, useMemo, useCallback, lazy, Suspense, useTransition, useDeferredValue, <Profiler>, the React Compiler "Introduction", "Installation" and "Incremental Adoption" pages (raw markdown from reactjs/react.dev). Could not do: I could not run the React DevTools browser extension in this sandbox, so what I say about the DevTools Profiler panel comes from reading, not from using it here. I did not test on a real phone or with CPU throttling. I give no GitHub star counts.
Words used in this lesson
- A render is React calling your component function to get a description of the UI. It is not the same as updating the screen.
- A commit is React applying the changes to the real DOM. Many renders end in a commit that changes nothing.
- Profiler is a React tool that records which components rendered and how long it took. There is a panel in React DevTools, and a
<Profiler>component for code. - Memoization means remembering the result of a calculation so you can reuse it when the inputs are the same.
memowraps a component so it skips rendering when its props are shallowly equal to last time.- Shallow equality means comparing each prop with
Object.is. Two different objects with the same content are not equal. - Referential equality means "is it the very same object in memory?".
useMemoremembers a calculated value between renders.useCallbackremembers a function between renders.- Virtualisation (windowing) means rendering only the rows you can see, plus a few more, instead of all of them.
- Code splitting means cutting your JavaScript into several files that load when needed.
lazyis the React function that loads a component's code only when it is first rendered.- A transition is an update React is allowed to interrupt, so urgent things like typing stay smooth.
useDeferredValuegives you a copy of a value that lags behind, so slow work can use the old value while the new one is prepared.- React Compiler is a build-time tool that adds memoization to your components automatically.
- A bundle is the JavaScript file your build produces for the browser.
Rule 0: measure first
The React docs say it clearly in the useMemo page: unless you loop over thousands of objects, a calculation is probably not expensive, and the way to check is to time it. I follow the same discipline for everything here.
There are three ways to measure, from cheap to rich:
- A render counter. Put
console.count("Row")or a counter in the body of a component. I used this for all counts in these lessons. Cheap and exact. - The
<Profiler>component. It wraps a part of the tree and calls youronRenderfunction after each commit with the time spent. - The React DevTools Profiler panel. You press record, use the page, press stop, and get a flame graph. It shows which components rendered and why (for example "props changed" or "hooks changed"). I could not run it in my sandbox, but it is the standard tool for finding the real culprit in a big app.
Here is the <Profiler> component in code. According to the reference, it takes an id and an onRender callback, and the callback gets the phase, the actualDuration (time spent rendering this update) and the baseDuration (an estimate of the cost with no memoization at all):
import { Profiler } from "react";<Profiler id="x" onRender={(id, phase, actual, base) => console.log(phase, actual, base)}> <Host /></Profiler>Trap: profiling adds overhead, so the docs say it is disabled in production builds by default. I met this myself: I first measured my browser test with a <Profiler> in a production bundle and it never called onRender, so my test waited forever. I switched to performance.now() around flushSync. If you must profile production, use the special profiling build the docs describe.
Trap: measure in a production build for real numbers. In development, React does extra checks, and StrictMode renders everything twice. Dev numbers are good for counts and bad for milliseconds.
Why does a component render? Three reasons only
A component renders when:
- It renders for the first time.
- Its own state changed (or a context it reads changed).
- Its parent rendered. Yes, even if its props are the same. By default React re-renders the whole subtree below a component that rendered.
Reason 3 is the one that causes most "extra" renders. When you type in an input at the top of a page and the whole page below renders again, this is why. It is also not a bug. Most re-renders are cheap and harmless. They become a problem only when they are slow (heavy work per render) or numerous (thousands of components).
Now we fix the real cases, cheapest fix first.
Fix 1: move state down, or pass children
You met this in lesson 6. If a piece of state changes often (a typed text), keep it in a small component of its own, so the rest of the page is not its child. Or pass the rest as children. Both cost nothing. Always try these first.
Fix 2: memo, and why it fails so easily
memo wraps a component and says: "if the props are the same as last time, skip rendering me".
const MemoRow = memo(function Row({ item, onPick }) { ... });"The same" means shallowly equal: each prop is compared with Object.is. A string, number or boolean compares by value. An object, array or function compares by identity, and a new {} or () => {} created during a render is a new identity every time.
I measured this with a list of 5 rows and an input box above it. I typed 3 characters, which re-renders the parent 3 times, and counted how many times Row rendered in total:
function Panel({ Comp, mode }) { const [typed, setTyped] = useState(""); const [picked, setPicked] = useState(null); const pickInline = (id) => setPicked(id); // new function every render const pickStable = useCallback((id) => setPicked(id), []); // same function const onPick = mode === "stable" ? pickStable : pickInline; return ( <div> <input aria-label="typed" value={typed} onChange={e => setTyped(e.target.value)} /> <ul>{items.map(it => <Comp key={it.id} item={it} onPick={onPick} />)}</ul> </div> );}plain Row, inline handler Row renders: 15memo(Row), inline handler (memo defeated) Row renders: 15memo(Row), useCallback handler Row renders: 0Read the three lines:
- Plain row: 5 rows times 3 keystrokes is 15 renders. All wasted, because no row's data changed.
memobut an inline function as a prop: still 15.memois on, butonPickis a new function each render, so the props are never equal. This is the number one reason "memo does not work".memoplususeCallback: 0 renders. The function keeps the same identity,itemis the same object, so every row is skipped.
So memo only works if every prop is stable. A stable function comes from useCallback, a stable object from useMemo or from moving it outside the component. The React docs say the same, and add a warning about custom comparison functions: if you give memo a second argument arePropsEqual, you must compare every prop including functions, or an old closure will keep seeing old state.
Trap: memo is a hint, not a promise. React may still re-render a memoized component if it needs to. Never rely on it for correctness (for example, to avoid running a side effect).
Trap: memo does not stop re-renders caused by the component's own state or by a context it reads. It only filters re-renders that come from the parent.
Trap: wrapping everything in memo is not free. Every memoized component compares its props on every parent render. If a component is cheap and its props change anyway, memo adds work and gains nothing.
useCallback is only a tool for memo and dependency arrays
useCallback(fn, deps) returns the same function as long as deps do not change. By itself it does nothing useful. It helps in two places only:
- When you pass the function to a
memochild (as above). - When the function is in the dependency array of an effect or
useMemo, and you do not want that effect to run every render.
A useCallback on a function that is passed to plain <button onClick> buys nothing. DOM elements do not care if the function changed.
Fix 3: useMemo for slow calculations
useMemo(calc, deps) runs calc once and then returns the stored result until a dependency changes.
I made a component that does an honest slow loop (5 million square roots, about 10 milliseconds on my machine) on every render, plus an input. Typing re-renders it. I typed 3 characters and timed the calculation line in each render:
function Plain() { const [typed, setTyped] = useState(""); const total = slowSum(5_000_000); // runs on EVERY render ...}function Memoed() { const [typed, setTyped] = useState(""); const total = useMemo(() => slowSum(5_000_000), []); // runs once ...}without useMemo slowSum calls: 4 | render calc times (ms): 13.3, 10.6, 9.6, 10.0with useMemo slowSum calls: 1 | render calc times (ms): 9.9, 0.1, 0.0, 0.0Four renders (mount plus 3 keystrokes). Without useMemo the slow function ran 4 times, about 10 ms each. With it, it ran once. On a slow phone that 10 ms could be 50 ms, enough to make typing feel sticky.
The docs give a simple test for "expensive": log the time with console.time; if the total for an interaction is around 1 ms or more, memoising may be worth it. And they remind you that useMemo does not make the first render faster. It only skips repeated work.
Trap: useMemo is for skipping calculation, not for guaranteeing a value stays the same. React may throw cached values away in some situations. Do not put side effects or "must run once" logic in it.
Trap: if the dependency array has an object that is created fresh each render, useMemo recalculates every time and you have paid the overhead for nothing. Memoising is only as good as its inputs.
When memo, useMemo and useCallback are NOT worth it
- The component renders in under a millisecond. Skipping it is invisible.
- Props change on every render anyway (a fresh array from
.map). - You have not measured a problem.
- The code gets harder to read. Each
useCallbackwith its dependency array is a place for a stale closure bug.
A good rule is the one in the React Compiler docs: for new code, write plain code and let the compiler handle it (next section), and keep manual useMemo/useCallback for precise control.
The Profiler in numbers
To see memo in the Profiler, I made a host with a button and 10 child components that each take about 5 ms to render. I wrapped everything in <Profiler> and clicked the button once, which re-renders the host and children:
no memo update commit -> actualDuration 51.4 ms, baseDuration 51.4 msmemo update commit -> actualDuration 0.3 ms, baseDuration 51.5 msHow to read it. actualDuration is what this update really cost. baseDuration is the estimate of what it would cost if nothing was memoized. Without memo, both are about 51 ms (10 children times 5 ms, plus overhead). With memo, actualDuration fell to 0.3 ms, but baseDuration is still about 51 ms. That gap is exactly the benefit: a big gap between the two numbers means memoization is paying off. (This ran in a development build in jsdom, so use it for the shape, not for absolute speed.)
Fix 4: long lists, virtualisation
The biggest practical performance problem in React apps is not renders. It is too many DOM nodes. A table with 10,000 rows, where each row has 5 elements, is 50,000 DOM elements. The browser needs time and memory for each, even if React renders each only once.
Memoization cannot fix that. The fix is windowing: render only the rows that are visible (say 18) plus a small buffer, and move them as the user scrolls. The library react-window (version 2.3.4 here) does this. You give it the row count, a row height and a row component:
import { List } from "react-window";function VRow({ index, style }) { return <div style={style}><Row item={data[index]} /></div>;}<List rowComponent={VRow} rowCount={data.length} rowHeight={28} rowProps={{}} style={{ height: 400 }} />I built the same 10,000-row list two ways, each row with a bold id, a name, an email and a button. I bundled with esbuild in production mode and loaded it in headless Chromium 141 with Playwright. "Render ms" is the time inside flushSync(root.render(...)), which includes React rendering and committing. "Until paint" is the time until two animation frames later, so it includes the browser's layout work. Three runs:
{"mode":"full","renderMs":139,"untilPaintMs":790,"domNodes":50006,"rows":10000,"heapMB":19.6}{"mode":"virtual","renderMs":16,"untilPaintMs":75,"domNodes":115,"rows":18,"heapMB":9.5}{"mode":"full","renderMs":500,"untilPaintMs":1727,"domNodes":50006,"rows":10000,"heapMB":19.6}{"mode":"virtual","renderMs":15,"untilPaintMs":59,"domNodes":115,"rows":18,"heapMB":9.5}{"mode":"full","renderMs":627,"untilPaintMs":1808,"domNodes":50006,"rows":10000,"heapMB":19.6}{"mode":"virtual","renderMs":17,"untilPaintMs":68,"domNodes":115,"rows":18,"heapMB":9.5}What the numbers say:
| All 10,000 rows | Virtualised | |
|---|---|---|
| DOM nodes | 50,006 | 115 |
| Rows in the DOM | 10,000 | 18 |
| JS heap | 19.6 MB | 9.5 MB |
| React render + commit | 139 to 627 ms (varied a lot) | 15 to 28 ms |
| Until painted | 790 to 1808 ms | 59 to 75 ms |
The exact milliseconds vary a lot on my sandbox machine (look at the full-list column: 139, 500, 627), so trust the shape, not the digits. The counts are stable: 50,006 nodes against 115. A user with a slower phone would see the full list freeze for seconds.
What virtualisation costs you:
- Row heights. The library needs to know row heights (fixed is simplest and fastest. The docs warn that dynamic heights are less efficient).
- Find in page and screen readers. Rows that are not in the DOM cannot be found with Ctrl+F, and assistive tech sees only the rendered ones. For tables, think about accessibility and pagination.
- Layout complexity. Sticky headers, variable columns and "scroll to item" need care.
If your list has under about 100 to 200 simple rows, do not bother. For thousands, virtualise. Often the best option is plain pagination or "load more", because users rarely need 10,000 rows on one page.
Fix 5: code splitting with lazy and Suspense
Rendering speed is half of the story. The other half is how much JavaScript the browser must download and run before the page works. A chart library or a rich text editor can add hundreds of kilobytes that most users never open. lazy loads a component's code only when it is first rendered:
import { lazy, Suspense, useState } from "react";const Heavy = lazy(() => import("./Heavy.jsx")); // call lazy OUTSIDE componentsfunction App() { const [show, setShow] = useState(false); return ( <div> <button onClick={() => setShow(true)}>show chart</button> {show && ( <Suspense fallback={<p>loading chart...</p>}> <Heavy /> </Suspense> )} </div> );}Two rules from the lazy reference: call lazy at the top level of a module (not inside a component, or the state resets on each render), and the import() must resolve to an object whose default is a component. While the code loads, the component suspends and the nearest Suspense shows its fallback.
I measured it with esbuild's --splitting. As a stand-in "heavy" component, I used one that imports Redux Toolkit. First, importing it normally:
l7/split/eager/main-eager.js 240.1kb (gzip: 77,573 bytes)Then with lazy(() => import("./Heavy.jsx")):
main-lazy.js 217.3kb (gzip: 68,985 bytes)Heavy-S7FRMRON.js 21.7kb (gzip: 8,705 bytes)chunk-AXYECJVO.js 1.3kbThe first load now ships 217 kB instead of 240 kB (about 8.6 kB gzipped less), and the 21.7 kB for the heavy part loads only when the user clicks. The saving here is small because my "heavy" part is small. With a real chart or editor library of 300 kB, the effect is large. The method is the same: find big parts that are not needed on first paint, and lazy load them.
Good places to split: routes (each page loads on navigation), modals, charts, rich text editors, and rarely used admin screens. Bad places: tiny components, or things needed right away (you will just add a spinner).
Trap: lazy loading creates a loading state in the middle of an interaction. If a click shows a spinner for 300 ms, the user feels it. You can preload by calling the same import() early (on hover or after the page is idle), so the code is ready when needed.
Fix 6: transitions and useDeferredValue for slow updates you cannot make fast
Sometimes the work is really slow and cannot be skipped: filtering and rendering a big result list on each keystroke. The input should still feel instant. React lets you say "this update is not urgent":
useTransitiongives youstartTransition(fn)andisPending. State updates insidefnare low priority. If something urgent comes in (the user types again), React can drop the old low-priority render and start over.useDeferredValue(value)gives you a copy ofvaluethat lags behind. React first renders with the old value (fast), then tries to render with the new value in the background.
function Search() { const [q, setQ] = useState(""); const dq = useDeferredValue(q); // lags behind q return ( <div> <input value={q} onChange={(e) => setQ(e.target.value)} /> {/* urgent: uses q */} <Results query={dq} /> {/* slow: uses dq */} </div> );}const Results = memo(function Results({ query }) { /* 100 slow items */ });Note the memo on Results. When only q changes and dq still has the old value, Results gets the same props and is skipped, which is how the first, fast render stays fast.
I tested this in Chromium. Results renders 100 items, each costing about 0.2 ms of busy work. I typed 8 characters with Playwright (keyboard.type with no delay) and counted how many times Results rendered. I ran it four times:
plain: resultsRenders 8, 8, 8, 8 keystrokes took 283 to 303 msdeferred: resultsRenders 4, 5, 7, 8 keystrokes took 116 to 238 msWith the plain version, every one of the 8 keystrokes blocked the page while the results rendered: 8 renders, about 290 ms. With useDeferredValue, React often skipped intermediate values: between 4 and 8 renders, and typing finished faster. But the result varied between runs, and in the last run it did all 8. Deferring does not promise fewer renders. It promises that urgent updates (typing) are not stuck behind the slow ones. It helps most when the slow part is really slow, or the user types quickly. If your slow work is only 20 ms, you may see no difference.
The docs also mention two things about these tools:
- Transition updates cannot control a text input. The input's own
valuemust be urgent state (that is why I useqfor the input anddqfor the results). - With
Suspense, a transition or a deferred value keeps showing the old content while the new content loads, instead of the fallback spinner.isPendinglets you dim the old content.
const [isPending, startTransition] = useTransition();<button onClick={() => startTransition(() => setTab("posts"))}>Posts</button><div style={{ opacity: isPending ? 0.6 : 1 }}>...</div>Trap: transitions do not make slow code fast. The slow render still takes the same total time. They make the page responsive while it happens. If you have a 2-second render, fix the render.
React Compiler: memoization that writes itself
Writing memo, useMemo and useCallback by hand is tedious and easy to get wrong (I showed above how one inline function defeats memo). The React Compiler is a build-time tool that adds this memoization for you. The React docs say it is now stable, understands the "Rules of React", and has been used in production at Meta. It is a Babel plugin (babel-plugin-react-compiler), also supported through Vite and Next.js 15.3.1 and newer. It works best with React 19 and also supports React 17 and 18 through a target setting.
What does it actually do? I compiled a small component with the real plugin (version 1.0.0) and read the output. The input:
export function List({ items, onPick }) { const [typed, setTyped] = useState(""); const shown = items.filter(i => i.name.includes(typed)); return (<div> <input value={typed} onChange={e => setTyped(e.target.value)} /> <ul>{shown.map(i => <Row key={i.id} item={i} onPick={() => onPick(i.id)} />)}</ul> </div>);}The compiled output (shortened, but real):
import { c as _c } from "react/compiler-runtime";export function List(t0) { const $ = _c(17); // a cache array with 17 slots const { items, onPick } = t0; const [typed, setTyped] = useState(""); ... if ($[0] !== items || $[1] !== onPick || $[2] !== typed) { ... const shown = items.filter(t3); ... t1 = shown.map(t5); $[0] = items; $[1] = onPick; $[2] = typed; $[3] = t1; $[4] = t2; } else { t1 = $[3]; t2 = $[4]; } ...}Read this like a human. The compiler creates a small cache array $ for the component. Before computing a piece, it checks "are my inputs the same as last time?" ($[0] !== items ...). If they are the same, it reuses the stored value (t1 = $[3]). That is useMemo and useCallback applied everywhere, even to JSX elements, which is something people rarely do by hand. Note the handler e => setTyped(...) is cached once with a sentinel check, so it never changes.
Then I ran my earlier experiment again. Same component as before: an input and 5 rows, with a new inline function passed as a prop, and no memo, useMemo or useCallback anywhere. I compiled the file with the plugin and ran the same typing test on the plain file and on the compiled file:
plain code, no memo anywhere Row renders for 3 keystrokes: 15same code after React Compiler Row renders for 3 keystrokes: 0Fifteen renders to zero, with no change to the source code. That is the promise of the compiler, shown with a real run.
What to know before using it:
- Install.
npm install -D babel-plugin-react-compiler@latest, and in Babel it must run first in the plugin list, per the docs. The install page also suggests theeslint-plugin-react-hooksrules that point out code the compiler cannot handle. - It follows the Rules of React. Components must be pure, state must not be mutated, hooks must be called at the top level. Code that breaks the rules may be skipped or may behave in surprising ways. The compiler skips what it cannot prove safe.
- Adoption can be gradual. The docs show
compilationMode: 'annotation', where only components marked with"use memo"are compiled. You can also opt a component out with"use no memo". - It memoizes components and hooks only. The docs point out that a plain helper function called from a component, even an expensive one, is not memoized across components. If a calculation is really expensive and used in many places, add your own caching around it.
- Keep your old
useMemoanduseCallback. The docs recommend leaving existing memoization in place, because removing it can change the compiled output. For new code, write plain code and adduseMemo/useCallbackonly when you need precise control. A common reason is a value used as an effect dependency, where you want exact control over when the effect runs. - It does not fix everything. It will not virtualise your list, shrink your bundle, or make a slow function fast. It reduces extra re-renders. Fixes 1, 4, 5 and 6 are still yours.
Trap: "The compiler means useMemo and memo are dead, so I will not learn them." Wrong for interviews and for reading existing code. Millions of lines of code use them, the compiler is still opt-in, and you need to understand what it generates to debug it. Know the manual tools, and know the compiler is the default way forward.
A simple checklist for a slow screen
- Reproduce it, in a production build if you can. Throttle the CPU in the browser tools to imitate a phone.
- Record with the Profiler. Which component is slow, and why did it render?
- Is it too many DOM nodes? Virtualise or paginate.
- Is it too much JavaScript on load? Split by route, lazy load heavy parts, check what is in the bundle.
- Is it a render that should not happen? Move state down, pass
children, stabilise props, thenmemo. - Is it one slow calculation?
useMemoit, or move the work (server, worker, precompute). - Is it slow but unavoidable, and typing suffers? A transition or
useDeferredValue. - Check again with numbers. If nothing improved, undo the change.
Try it
Use a scratch Vite project.
- Make a parent with an input and a list of 200 rows, each row doing a 1 ms busy loop. Type quickly and feel the lag. Add
console.count("Row")inRowand count renders for 5 keystrokes. - Wrap
Rowinmemo. Does the count change? Why or why not? Then add the right fix for the prop that breaks it. - Add a
slowSumcalculation in the parent that does not depend on the input. Time it withconsole.time. Wrap it inuseMemo. Time it again. - Render 10,000 rows twice: all at once, and with
react-window. Compare the number of DOM nodes in the browser console withdocument.getElementsByTagName("*").length. - Move a big import behind
lazyand look at the network panel. When is the extra file requested? - Make the results list slow (100 items at 0.2 ms) and add
useDeferredValue. Type fast. Count result renders. Is it always fewer? - If your build tool supports it, add
babel-plugin-react-compilerand run exercise 2 again without anymemo.
Answers
- 200 rows times 5 keystrokes is 1000 renders and about 200 ms per keystroke, which you can feel.
- If you pass an inline function or a fresh array/object as a prop, the count does not change, because
memosees new props every time. Wrap the function inuseCallback(and objects inuseMemo), and the count drops to 0 for typing. - Without
useMemo, the time is logged on every keystroke. WithuseMemoand correct dependencies, it is logged once at full cost, then near 0. - About 50,000 nodes with 10,000 five-element rows against about a hundred with virtualisation (my numbers: 50,006 against 115).
- The heavy file is requested only when the lazy component first renders, for example after the click.
- Not always. In my four runs the result renders were 4, 5, 7 and 8 against a constant 8. The benefit is a more responsive input, not a promised drop in render count.
- The count for the compiled file should drop to 0 without
memo, as in my run (15 to 0).
Interview questions
A React page is slow. How do you find out why? (Must know)
I measure before I change anything. I reproduce it, ideally in a production build with the CPU throttled. Then I record a session in the React DevTools Profiler and look for components that render often or take long, and I check why they rendered. If I do not have DevTools, I use the <Profiler> component or a render counter. Then I decide which kind of problem it is: too many DOM nodes, too much JavaScript on load, wasted re-renders, one slow calculation, or an update that is slow but needed. Each has a different fix, and I re-measure after.
Why does a component re-render? (Must know)
Three reasons: first render, a change in its own state or in a context it reads, or its parent rendered. By default a parent render re-renders all its children, even when their props are the same. This is normal and usually cheap. It only needs fixing when it is slow or when there are very many components.
When does React.memo help, and when does it not? (Must know)
It helps when a component is expensive to render, its parent re-renders often, and its props usually stay the same. It does not help if any prop changes identity each render, because memo uses shallow comparison. I proved this with 5 rows and 3 keystrokes: plain gave 15 renders, memo with an inline handler still gave 15, and memo with useCallback gave 0. memo also does not stop updates from the component's own state or from context.
What is the difference between useMemo and useCallback? (Must know)
useMemo caches the result of calling a function, useCallback caches the function itself. useCallback(fn, deps) is the same as useMemo(() => fn, deps). I use useMemo to skip an expensive calculation or to keep an object's identity stable, and useCallback to keep a function's identity stable when it is passed to a memo child or listed as an effect dependency. Neither speeds up the first render.
Is it good practice to wrap every function in useCallback? (Trap)
No. It adds code and a dependency array to maintain, which is a source of stale closure bugs, and it gains nothing if the function goes to a plain DOM element or a component that is not memoized. I use it when I pass the function to a memo component, or put it in a dependency array. With the React Compiler on, I rely on the compiler and only add useCallback when I need exact control.
How would you render a list of 10,000 rows? (Must know)
Not as 10,000 rows. The cost is mostly DOM nodes. In my test the plain list made about 50,000 DOM nodes and took 139 to 627 ms to render and up to about 1.8 s to paint, while the windowed list with react-window made 115 nodes and took about 15 to 28 ms. I would first ask if the user needs all rows at all. If yes, I virtualise with fixed row heights. If not, I paginate or filter on the server. I also mention the trade-offs: browser find, accessibility and variable heights.
What does lazy do and what do you need around it? (Must know)
lazy(() => import("./X")) delays loading the component's code until it first renders. While the code loads, the component suspends, so I need a <Suspense fallback={...}> above it. I call lazy at the top level of a module, not inside a component. The point is a smaller first bundle: in my test the main file went from 240 kB to 217 kB, and the heavy part (21.7 kB) loaded on demand. I split at routes and at large optional parts like charts and editors, and I can preload on hover to hide the delay.
What is the difference between useTransition and useDeferredValue? (Senior signal)
Both mark work as not urgent. useTransition wraps the state update I trigger (startTransition(() => setTab(...))) and gives me isPending. useDeferredValue wraps a value I receive and gives me a lagging copy, which is useful when I do not control the code that sets the value (for example a prop). The input itself must use urgent state, because transition updates cannot drive a text input. In my test with useDeferredValue the result list rendered 4 to 8 times for 8 keystrokes against a constant 8, so I explain that it improves responsiveness and does not guarantee a number of renders.
What does the React Compiler do, and does it replace useMemo and useCallback? (Senior signal)
It is a build-time Babel plugin that analyses components and hooks and inserts memoization automatically, using a cache array per component. In my test an input plus 5 rows with an inline function prop and no memo anywhere went from 15 renders to 0 after compiling. It mostly replaces the need to write useMemo, useCallback and memo by hand. I still keep them for precise control, like a stable effect dependency, and I do not remove existing ones without testing, because the docs say removal can change the output. It only memoizes components and hooks, not arbitrary helper functions, and it relies on code following the Rules of React.
Why can useMemo make things slower? (Trap)
Because it has a cost: it stores the value, compares the dependency array on every render, and holds memory. If the calculation is cheap, or the dependencies change on every render, I pay that cost and still recalculate. That is why the React docs say to measure first, for example with console.time, and consider it when the work takes around a millisecond or more over an interaction. I also remember that it does not speed up the first render.
How do you know a performance fix worked? (Senior signal)
By numbers from the same scenario before and after: render counts for a given interaction, Profiler actualDuration, DOM node count, bundle size, or a measured typing delay, ideally in a production build on a throttled CPU. In my experiments each fix has a stated number: 15 renders to 0, 4 calls to 1, 50,006 nodes to 115, 240 kB to 217 kB. If I cannot show a difference, I revert the change, because every optimisation adds code that someone must maintain.
A teammate wraps the whole app in memo and useMemo "to be safe". What do you say? (Trap)
I would explain that it is not free and not safe. Comparisons run on every render, the code becomes harder to read, and wrong dependency arrays create stale-data bugs. It also gives a false feeling of safety, because one inline function prop defeats memo anyway. I would suggest finding the real slow spots with the Profiler, fixing those, and turning on the React Compiler for the rest of the app.
Key takeaways
- Measure before you optimise: render counters, the
<Profiler>component, the DevTools Profiler. Profiling is off in production builds by default. - A component renders because it is new, its state or context changed, or its parent rendered. Most re-renders are harmless.
- Cheapest fixes first: move state down, or pass
children. memoneeds every prop to be stable. One inline function madememouseless (15 renders), anduseCallbackfixed it (0).useMemohelps only for really slow work or stable identities: 4 slow calls became 1.useCallbackalone does nothing, it matters only withmemoor dependency arrays.- Long lists are a DOM problem: 50,006 nodes became 115 with virtualisation.
lazyplusSuspensemoves optional code out of the first bundle. Transitions anduseDeferredValuekeep typing responsive, but do not make slow code fast.- The React Compiler adds memoization automatically: 15 renders to 0 on unchanged source. It follows the Rules of React, memoizes components and hooks only, and does not replace virtualisation or code splitting.
What's next
Next: Patterns, Server Components and Testing React. Fast code still has to be well organised and safe. You will see composition patterns, error boundaries that really catch (and do not catch) errors, portals, Server and Client Components in plain words, and a Testing Library test that really runs. To practise now, do exercises 1 and 2 and write down the render counts before and after your fix.
Published by Jainik · Last updated · How these lessons are made
Keep learning
Related lessons
- Unnecessary Re-renders: Finding and Fixing Them · Scenario Debugging
- Pagination, Filtering and Sorting · API Design
- Node.js Performance Basics: Event Loop Blocking, Streams, Workers · Backend in Depth
Part of the study guide
Related series