Lesson 9 · Performance
Performance and rendering
When a component re-renders, memo, useMemo, useCallback, useTransition, useDeferredValue.
Loading lesson…
Lesson 9 · Performance
When a component re-renders, memo, useMemo, useCallback, useTransition, useDeferredValue.
Loading lesson…
Back to the kitchen. The head chef (a parent component) has helpers under them (child components). Whenever the head chef changes their recipe – adds salt, say – all the helpers go through their own recipes again and check whether their plate still matches. Most of the time they find it does, and change nothing. Going through a recipe like this is called a render.
Going through a recipe is quick. What's expensive is actually swapping something on the table – and React does that only where the result really differs. That's why most renders are cheap and don't need any attention.
The trouble starts when one helper has a lot of work – recalculating a table with thousands of rows, say – and the others have to wait for them. Then the app is slow: you type into a field and the letters appear with a delay. This lesson is about how to find that waiting and get rid of it.
memo); putting less important work off until later (useTransition).A component renders (React calls its function) when:
Point 2 surprises people the most. An example:
function Dashboard() {const [note, setNote] = useState('')return (<><input value={note} onChange={(e) => setNote(e.target.value)} /><ExpensiveChart year={2025} /></>)}
note state in Dashboard changes.Dashboard(). It returns JSX with <ExpensiveChart year={2025} />.ExpensiveChart() too – even though year is still 2025. React doesn't compare props on its own. The head chef changed the recipe, so the helper has to go through theirs.Two techniques that speed up an app without a single memo. Both build on the fact that what re-renders is the component with the changed state and everything under it – nothing above it or next to it.
In the example above, only the input needs note. The chart knows nothing about it. So why is the state in a component that also contains the chart? Let's move it into a separate component:
function Dashboard() {return (<><NoteInput /> {/* the note state is inside */}<ExpensiveChart year={2025} /></>)}function NoteInput() {const [note, setNote] = useState('')return <input value={note} onChange={(e) => setNote(e.target.value)} />}
Now a change of note re-renders only NoteInput. Neither Dashboard nor the chart finds out anything – the salt changed in just one sauce.
What if the state has to be in the wrapping component – a background colour used by the wrapper around the chart, say? Then the children trick helps:
// ❌ ColorPicker creates the chart itself → re-renders it when the colour changesfunction ColorPicker() {const [color, setColor] = useState('white')return (<div style={{ background: color }}><input type="color" onChange={(e) => setColor(e.target.value)} /><ExpensiveChart year={2025} /></div>)}// ✅ The parent creates the chart and passes it as childrenfunction ColorPicker({ children }: { children: ReactNode }) {const [color, setColor] = useState('white')return (<div style={{ background: color }}><input type="color" onChange={(e) => setColor(e.target.value)} />{children}</div>)}<ColorPicker><ExpensiveChart year={2025} /></ColorPicker>
Why does it work? The <ExpensiveChart /> element is now created in ColorPicker's parent. When the colour changes, React re-renders ColorPicker, but children is still the same element as before – nobody created it again. React sees it hasn't changed and skips the chart. A finished dish brought in from another kitchen doesn't get cooked again.
When organising isn't enough, it's time for a helper who looks at the order before working: "Is it the same as last time? Then I do nothing and hand over the previous plate." That's memo.
const ExpensiveChart = memo(function ExpensiveChart({ year }: { year: number }) {// …an expensive calculation…})
There's a catch, though. The helper doesn't compare orders by content, but by whether it's the same slip of paper (Object.is for each prop separately). For numbers and text that's the same thing. But objects and functions are created anew on every render of the parent – it's a new slip, even though the same thing is written on it:
function Dashboard() {const [note, setNote] = useState('')// A NEW object and a NEW function on every renderconst options = { year: 2025, color: 'blue' }const handleClick = (point: Point) => console.log(point)return <ExpensiveChart options={options} onPointClick={handleClick} />// memo compares: options === the previous options? NO → re-renders anyway}
The solution is to remember those values, so the parent hands over the same slip on the next render:
const options = useMemo(() => ({ year: 2025, color: 'blue' }), []) // the same objectconst handleClick = useCallback((point: Point) => console.log(point), []) // the same function
| Tool | What it does | When to use it |
|---|---|---|
memo(Comp) | Skips the render when all props are the same (by reference). | An expensive component that often re-renders with the same props. |
useMemo(fn, deps) | Remembers the RESULT of a calculation. | An expensive calculation; or a stable object/array for a memo component or an effect dependency. |
useCallback(fn, deps) | Remembers a FUNCTION (= useMemo(() => fn)). | A function passed to a memo component or used in an effect’s dependencies. |
What you see: A parent with a counter and four child cards. A card flashes yellow when it renders. The cards differ in whether they're wrapped in memo and what props they get.
Try it:
memo. It gets a style object and a function created right in the render – a new slip every time.useMemo and useCallback. It doesn't flash.The takeaway: memo helps only when the props really are the same references. A single object or function created in the render knocks it out.
Loading the interactive part…
In a restaurant, the guest who has just walked in comes first – you have to greet them right away. Polishing the cutlery can wait, and when another guest walks in while you're doing it, you stop polishing and come back to it later.
React can do the same. Some updates are urgent – typing into a field, a click; the user expects an immediate response. Others are non-urgent – re-rendering a big list of results. React can start a non-urgent render and, when an urgent one comes, interrupt it and come back to it later. You just have to tell it which is which.
function Search() {const [query, setQuery] = useState('')const deferredQuery = useDeferredValue(query) // "a delayed copy"return (<><input value={query} onChange={(e) => setQuery(e.target.value)} /><SlowList query={deferredQuery} /></>)}const SlowList = memo(function SlowList({ query }: { query: string }) { … })
What happens when you type the letter "a":
query is "a", but deferredQuery is still the old value "". The input shows "a" at once. SlowList gets the same prop as last time and thanks to memo is skipped.deferredQuery is "a" too – now SlowList gets calculated.What you see: A field and under it a list of 400 artificially slowed-down items (each render of the list takes about 120 ms). The checkbox switches useDeferredValue on. When the list shows a stale value, it fades.
Try it:
The takeaway: The slow list still takes just as long. But it no longer blocks typing – React calculates it "between the letters" and interrupts it.
Loading the interactive part…
When you have access to the setter, you can mark a non-urgent change directly. Typically switching tabs: the click responds at once, but rendering the content of a slow tab can wait – and if you click another tab in the meantime, React throws away the half-done work.
const [tab, setTab] = useState('about')const [isPending, startTransition] = useTransition()function selectTab(next: Tab) {startTransition(() => setTab(next)) // this change is non-urgent}{isPending && <Spinner />} // while the new tab is being calculated
What you see: Three tabs. "Posts" is artificially slow (its render takes about 300 ms). The checkbox switches useTransition on.
Try it:
The takeaway: A transition turns a slow switch into interruptible background work. The user can keep clicking and sees that something is happening.
Loading the interactive part…
| useTransition | useDeferredValue | |
|---|---|---|
| What you wrap | A state update (setX) | A value |
| When | You have access to the setter (switching tabs, navigation) | The value comes from outside (props), or you want the field immediately and the list later |
| How you tell it’s waiting | isPending | value !== deferredValue |
lazy(() => import('./Page')) + Suspense. The browser downloads a page's code only when it needs it. This course loads each lesson separately.// Lazy loading of a heavy component (a chart, an editor, a map)const Chart = lazy(() => import('./Chart'))<Suspense fallback={<Spinner />}>{showChart && <Chart data={data} />}</Suspense>
Try typing into the note and watch the price flash. One wrap stops it.
CHALLENGE: Stop the needless flashing
Price flashes on every re-render. Right now it flashes even when you type into the note,
which has nothing to do with the price.
1. Wrap the Price component in memo: const Price = memo(function Price(…) { … })
(import memo from 'react')
It will then flash only when the price changes.Loading the interactive part…
The first challenge is exactly the example from sections 1–3: fix it first by organising the kitchen, then by memoising, and compare the two solutions. The second practises section 4 – the field must react immediately, even though the list takes longer to calculate.
Find out why typing is slow and fix it – first with structure, then with memoisation.
Medium and hard challenges are a bonus for signed-in readers. Signing up is free – just an e-mail, no password.
The guest at the door (typing) comes before polishing the cutlery (the list of results).
Medium and hard challenges are a bonus for signed-in readers. Signing up is free – just an e-mail, no password.
children.useMemo/useCallback.useRenderFlash)useRenderFlash – from the file src/course/useRenderFlash.ts. A teaching helper hook: the element flashes on every render so you can see what re-renders. You won't need it in your own code./*** A teaching helper hook: the element briefly "flashes" after every render of the component.* An effect without a dependency array runs after every commit – exactly what we want to visualize.*/export function useRenderFlash<T extends HTMLElement>() {const ref = useRef<T>(null)useEffect(() => {const el = ref.currentif (!el) returnel.classList.remove('flash')void el.offsetWidth // restart the CSS animationel.classList.add('flash')})return ref}
row, stack, card, list, btn, input or muted are the course's ready-made styles in src/styles.css (row = side by side, stack = stacked, card = bordered box, muted = grey text).