Performance Optimization
Measure first, then cut the work React does and the bytes the browser downloads
Guess first
Pick an answer. You will see if you were right at the end of the lesson.
You wrap a child in React.memo but it still re-renders on every parent render. What is the most likely cause?
Real-world analogy
What is it?
React performance work has two sides. The first is render work: how many components run, and how often, after a change. The second is delivery: how many bytes the browser downloads, parses and runs before the page is usable. Tools like memo, useMemo and useCallback cut render work, and lazy loading and virtualization cut delivery and DOM size. All of them have a cost in complexity, so you apply them where a measurement points.
Real-world relevance
A dashboard feels slow when someone types in a filter box. The Profiler shows that every table row re-renders on each keystroke. The team memoises the row and passes a stable callback, and unchanged rows stop re-rendering. Later, the first load is slow. The bundle analysis shows the reports page and its chart library in the main file. Moving that route to lazy loading cuts the first download.
Key points
- Measure before optimizing — Use the React Profiler to see which components render and for how long. Use the browser Performance tab to find slow scripts, so you fix the real bottleneck.
- Why components re-render — A component renders when its state changes, when its parent renders, or when a context it reads changes. Props change only because a parent rendered, so the parent render is the real cause.
- React.memo skips renders — It wraps a component so React skips it when every prop is unchanged. Objects and functions count as unchanged only if they are the same reference. It pays off when renders are costly and props are stable.
- Stable props make memo work — An inline {} or arrow function is a new reference each render, so memo sees a change. Use useCallback and useMemo, or define the value outside the component.
- useMemo for slow work — It remembers a computed value until its dependencies change. Use it for a measured slow calculation, not for every small expression.
- Split code by route — lazy(() => import("./Page")) with Suspense downloads a page only when the user visits it. This lowers the first load for everyone.
- Virtualize long lists — Rendering ten thousand rows creates ten thousand DOM nodes. A virtualized list renders only the rows near the viewport.
- Debounce busy input — Wait for a short pause before filtering or calling the API. This cuts the work done on each keystroke.
- Images, bundle and vitals — Compress images and ship less JavaScript. LCP measures how fast the main content shows. INP measures how fast the page reacts to input, and CLS measures layout jumps.
Quick check
Answer from memory — scrolling up first makes it stick less.
What should you do first when a React page feels slow?
What does wrapping a route component in lazy and Suspense achieve?
Code example
import { lazy, memo, Suspense, useCallback, useState } from 'react';
type Row = { id: number; name: string };
const RowItem = memo(function RowItem(
{ row, onSelect }: { row: Row; onSelect: (id: number) => void }
) {
return (
<li>
<button type="button" onClick={() => onSelect(row.id)}>{row.name}</button>
</li>
);
});
export function RowList({ rows }: { rows: Row[] }) {
const [selected, setSelected] = useState<number | null>(null);
const handleSelect = useCallback((id: number) => setSelected(id), []);
return (
<>
<p>Selected: {selected ?? 'none'}</p>
<ul>
{rows.map(r => <RowItem key={r.id} row={r} onSelect={handleSelect} />)}
</ul>
</>
);
}
// fragment: ReportsPage lives in its own file and has a default export
const ReportsPage = lazy(() => import('./ReportsPage'));
export function ReportsRoute() {
return (
<Suspense fallback={<p>Loading report...</p>}>
<ReportsPage />
</Suspense>
);
}Line-by-line walkthrough
- 1. This is a fragment of a file. RowItem is wrapped in memo, so React skips it if row and onSelect are the same objects as before.
- 2. The row button calls onSelect with its id. It is a real button, so the keyboard works.
- 3. RowList keeps the selected id in state. Changing it re-renders RowList.
- 4. handleSelect is wrapped in useCallback with no dependencies, so it is the same function on every render.
- 5. Each RowItem gets the same row object and the same callback, so memo skips the rows when selected changes.
- 6. ReportsPage is loaded with lazy, and ReportsRoute wraps it in Suspense, with a fallback shown while the code downloads.
Spot the bug
import { memo } from 'react';
type Item = { id: number; name: string };
const Row = memo(function Row({ item, style }: { item: Item; style: { color: string } }) {
return <li style={style}>{item.name}</li>;
});
function List({ items }: { items: Item[] }) {
return <ul>{items.map(i => <Row key={i.id} item={i} style={{ color: 'red' }} />)}</ul>;
}Need a hint?
Show answer
Explain like I'm 5
Fun fact
Hands-on challenge
More resources
- memo (React)
- lazy (React)
- Free Full-Stack Playbook (React and TypeScript lessons) (Teamz Lab)
Lock it in