Skip to content
wannacode

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.

React

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
  1. 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.
  2. Every key changes draft. React renders App again, and with it every child: ProductList and each card, although the list is the same.
  3. 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.
  4. Now a key renders only App and the field. The props of ProductList are the same, so it is skipped, and the counter stays.
  5. "Search" changes query. The array is new, so ProductList renders. This render is needed: the list shows two products now.
  6. Measure first: a counter in the console or the Profiler in DevTools. Fix only the renders that are both extra and slow.
React can skip a child only if you tell it to compare the props: that is 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.

React

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.

Try it

Fill in

How does the expensive list stay the same between renders?

const visible = (
  () => PRODUCTS.filter((p) => p.name.includes(query)),
  [query],
);

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.

React

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.

React

Test

Check yourself

1. The product page is slow. What is the first thing you do?
2. ProductList is in memo. App passes products={PRODUCTS.filter(…)} and onAdd={() => add()}. You type in the field of App. Does ProductList render?
3. The catalogue has 5000 products and the page opens slowly. What helps most?
4. What does lazy loading a component (lazy / defineAsyncComponent) give in a real project?

Made by Ivo Gergov as part of Evo Tech's campaign for accessible programming education ·