Speed: when a component renders again
In short: In React a component renders again when its state or its parent changes; you measure first, then memo, useMemo and useCallback stop the needless renders.
Problem
Every letter renders the whole list
At the top there is a search field and a "Search" button. The list is filtered only after "Search". ProductList writes to the console every time it renders. Type "milk" in the field and watch the console.
Console
Four letters, four new "rendering ProductList" lines. The list is the same: you have not pressed "Search" yet. Here there are eight products and you do not feel it. In a real catalogue there are 2000, each with an image and a price, and the field starts to lag behind your fingers.
The idea
When a component renders again
A component renders again when the data it shows change. But what "its data" means, the two libraries decide differently. Both give the right result; the difference is how much work they do.
React renders again the component whose state changed, and all of its children, down the whole tree. By default it does not check whether the props of a child are the same. Here draft is in App, so every letter renders App, ProductList and every card.
This is usually fast: React compares the result with the old page and changes only the difference in the DOM. It gets expensive when the subtree is large or the calculation in it is heavy.
Measure first, fix after. A counter in the console, like here, is the simplest measure. React DevTools have a Profiler tab: you record, type in the field and see which component rendered how many times and for how many milliseconds. You fix only a render that is both extra and slow.
See it
See who lights up
const ProductList = memo(function ProductList({ products, onAdd }) { console.log(`rendering ProductList: ${++renders}`); return products.map((p) => <ProductCard key={p.id} product={p} onAdd={onAdd} />);});
function App() { const [draft, setDraft] = useState(''); const [query, setQuery] = useState(''); const [count, setCount] = useState(0); const visible = useMemo(() => filterByName(PRODUCTS, query), [query]); const add = useCallback(() => setCount((c) => c + 1), []); return ( <> <input value={draft} onChange={(e) => setDraft(e.target.value)} /> <button onClick={() => setQuery(draft)}>Search</button> <ProductList products={visible} onAdd={add} /> </> );}Step by step, in words
- App keeps the text of the field (draft) and the search (query). ProductList draws the cards. The console counts how many times the list renders.
- Every key changes draft. React renders App again, and with it every child: ProductList and each card, although the list is the same.
- memo(ProductList) compares the new props with the old ones. useMemo keeps the same array while query is the same, and useCallback keeps the same add.
- Now a key renders only App and the field. The props of ProductList are the same, so it is skipped, and the counter stays.
- "Search" changes query. The array is new, so ProductList renders. This render is needed: the list shows two products now.
- Measure first: a counter in the console or the Profiler in DevTools. Fix only the renders that are both extra and slow.
memo. And the comparison makes sense only if the props really are the same: the array and the function must be kept between renders.Try it
Only "Search" renders the list
Make typing in the field and "Add" not render ProductList again. Only "Search" should render it, because only then does the list change. The check types "milk", adds two products, presses "Search" and counts the "rendering ProductList" lines in the console.
Console
Solution
App.jsximport { useCallback, useMemo, useState } from 'react';
import ProductList from './ProductList.jsx';
import { PRODUCTS } from './products.js';
export default function App() {
const [draft, setDraft] = useState('');
const [query, setQuery] = useState('');
const [count, setCount] = useState(0);
const visible = useMemo(() => PRODUCTS.filter((p) => p.name.toLowerCase().includes(query.toLowerCase())), [query]);
const add = useCallback(() => setCount((c) => c + 1), []);
function search(e) {
e.preventDefault();
setQuery(draft);
}
return (
<>
<header>
<form role="search" onSubmit={search}>
<input type="search" aria-label="Search" value={draft} onChange={(e) => setDraft(e.target.value)} />
<button>Search</button>
</form>
<p>In the cart: {count}</p>
</header>
<main>
<ProductList products={visible} onAdd={add} />
</main>
</>
);
}
ProductList.jsximport { memo } from 'react';
import { money } from './products.js';
let renders = 0;
function ProductList({ products, onAdd }) {
console.log(`rendering ProductList: ${++renders}`);
return (
<ul>
{products.map((p) => (
<li key={p.id}>
<span>{p.name}, {money(p.price)}</span>
<button type="button" aria-label={`Add ${p.name}`} onClick={() => onAdd(p)}>
Add
</button>
</li>
))}
</ul>
);
}
export default memo(ProductList);
products.jsexport const PRODUCTS = [
{ id: 1, name: 'White bread', price: 1.2 },
{ id: 2, name: 'Wholegrain bread', price: 1.9 },
{ id: 5, name: 'Fresh milk', price: 1.6 },
{ id: 6, name: 'Skimmed milk', price: 0.9 },
{ id: 7, name: 'White cheese', price: 7.9 },
{ id: 8, name: 'Yellow cheese', price: 9.5 },
{ id: 9, name: 'Butter', price: 3.4 },
{ id: 10, name: 'Eggs (10)', price: 2.8 },
];
export const money = (x) => '€' + x.toFixed(2);
style.cssbody { font-family: system-ui, sans-serif; margin: 0; }
header { display: flex; justify-content: space-between; align-items: center; gap: 8px; background: #f3efe6; padding: 8px 16px; }
main { padding: 0 16px; }
ul { list-style: none; padding: 0; }
li { display: flex; gap: 8px; align-items: center; margin: 6px 0; }
li span { flex: 1; }
button, input { font: inherit; }
The three parts go together:
- In
ProductList.jsx:export default memo(ProductList);. Now React compares the props before rendering. - In
App.jsx:const visible = useMemo(() => PRODUCTS.filter(…), [query]);. The array is the same whilequeryis the same. const add = useCallback(() => setCount((c) => c + 1), []);. The function is the same on every render. WithsetCount((c) => c + 1)it does not readcount, so the empty list[]is right.useCallback(() => setCount(count + 1), [])remembers the firstcountand the cart stops at 1: the check catches it.
memo alone is not enough: the filter and the function are new on every render of App, and memo sees new props.
Try it
Find the bug
ProductList is in memo, and the list still renders on every letter. Which line is to blame?Pick the line with the bug.
add is a new arrow on every render of App. memo sees a new onAdd and renders ProductList. It should be const add = useCallback(() => setCount((c) => c + 1), []);.Try it
Fill in
How does the expensive list stay the same between renders?
const visible = (
() => PRODUCTS.filter((p) => p.name.includes(query)),
[query],
);useMemo: runs the function on the first render and again only when a dependency in [query] changes. Otherwise it returns the same array.The idea
A long list: render less
The fastest element is the one that is not drawn. A person sees 10–20 products on the screen, not 2000. So a long list draws a part:
- "Show more": the first 20, then 20 more on a click. Only how many are shown is remembered.
- Pages: "1, 2, 3 …", often with the number in the address (
/?page=2), so it can be shared. - A virtual list (windowing): draws only the rows that are visible and swaps them while scrolling. It is hard to write, so you use a library: TanStack Virtual or react-window.
The keys from the lesson on lists help here too: with key={p.id} the 20 added products are new elements, and the old ones stay without being made again.
The idea
Lazy loading
Not every part of the page is needed at once. The product reviews, the map of the shops, the profile editor: they can load only when the person opens them. Then the first load downloads less code.
import { lazy, Suspense } from 'react';
const Reviews = lazy(() => import('./Reviews.jsx'));
<Suspense fallback={<p>Loading the reviews…</p>}> <Reviews /></Suspense>import('./Reviews.jsx') is a function call that loads the file and returns a Promise. lazy turns it into a component. While the file is coming, Suspense shows the fallback.
Console
Open the console: the line from reviews.js comes only after the click. To be honest, in the sandbox all files are already in the page, so here import() only runs the file later. In a real project Vite puts each such file apart and the browser downloads it from the server on the first click. The same goes for pages: with React Router every page can be lazy.
More precisely, if you are curious
React Compiler. If the project uses React Compiler, it adds this kind of remembering by itself when the code is built, and manual memo, useMemo and useCallback are rarely needed. The React docs recommend it for new code and keep useMemo and useCallback as a way of precise control.
State down. Another fix without memo: the field with its draft becomes a separate component SearchBox that reports up only on "Search" (onSearch(draft)). Then the letters change state in SearchBox, and App does not render at all. It is the rule "as high as needed and no higher" from the lesson on lifting state.
StrictMode. In development <StrictMode> renders each component twice, to catch impure components. The counter then grows by two. The built version does not do it.
useMemo is a hint. React may throw away the remembered result and calculate again. The code must be right without it.
Development mode is slower. Warnings and checks cost time. Measure the speed of the built version (vite build and vite preview), not only in development.
Exercise
Show more
The catalogue has 10 products and draws them all. Show the first 4, and below them "Showing 4 of 10" and a "Show more" button that adds 4 more. When all are shown, the button goes away. The check counts the products after every click and looks at the text and the button.
Console
Solution
App.jsximport { useState } from 'react';
import { PRODUCTS, money } from './products.js';
const PAGE = 4;
export default function App() {
const [limit, setLimit] = useState(PAGE);
const shown = PRODUCTS.slice(0, limit);
return (
<main>
<ul>
{shown.map((p) => (
<li key={p.id}>
<span>{p.name}, {money(p.price)}</span>
</li>
))}
</ul>
<p>{`Showing ${shown.length} of ${PRODUCTS.length}`}</p>
{limit < PRODUCTS.length && (
<button type="button" onClick={() => setLimit(limit + PAGE)}>Show more</button>
)}
</main>
);
}
products.jsexport const PRODUCTS = [
{ id: 1, name: 'White bread', price: 1.2 },
{ id: 2, name: 'Wholegrain bread', price: 1.9 },
{ id: 5, name: 'Fresh milk', price: 1.6 },
{ id: 6, name: 'Skimmed milk', price: 0.9 },
{ id: 7, name: 'White cheese', price: 7.9 },
{ id: 8, name: 'Yellow cheese', price: 9.5 },
{ id: 9, name: 'Butter', price: 3.4 },
{ id: 10, name: 'Eggs (10)', price: 2.8 },
{ id: 11, name: 'Apples (1 kg)', price: 2.2 },
{ id: 12, name: 'Bananas (1 kg)', price: 2.6 },
];
export const money = (x) => '€' + x.toFixed(2);
style.cssbody { font-family: system-ui, sans-serif; margin: 0; }
header { display: flex; justify-content: space-between; align-items: center; gap: 8px; background: #f3efe6; padding: 8px 16px; }
main { padding: 0 16px; }
ul { list-style: none; padding: 0; }
li { display: flex; gap: 8px; align-items: center; margin: 6px 0; }
li span { flex: 1; }
button, input { font: inherit; }
You keep only how many are shown: const [limit, setLimit] = useState(4);. The shown ones are calculated: PRODUCTS.slice(0, limit). The button is {limit < PRODUCTS.length && <button …>}, and the click is setLimit(limit + 4). A plain variable let limit = 4 changes on the click, but React does not render again: the check catches it.
Test
Check yourself
Related lessons
Section titled "Related lessons"- Builds on: Derived state · Lifting state up · Effects · Routing
- Tasks on this topic: A whole app