Most React apps are fast enough with no special work. But sometimes a page feels slow: typing lags, a list stutters, or a click takes a moment. This lesson explains why that happens and how to fix it with a few simple tools. It also explains the most important rule: measure first, then optimise.
Step 1: find the real problem
Do not add memo and useMemo everywhere “just in case”. They add code and need care, and often change nothing. Find the slow part first:
- Open React Developer Tools and use the Profiler tab. Click record, do the slow action, stop, and look at which components rendered and how long each took.
- Test with CPU throttling in the browser tools (Performance tab), because your computer is faster than many visitors' phones.
- Look at the Network tab. Many slow pages are slow because of large images, large JavaScript files or slow requests, not because of React.
A common order of fixes, from most to least effective: send less data and less code first (smaller images, code splitting), then make the work smaller (show only what is visible), and only then skip renders with memo.
Why components render too much
From the last lesson: when a component renders, its children render too, even if their props did not change. Usually that is cheap. But if a child is slow (a big chart or a long list), re-rendering it every time the parent changes is wasteful. For example, typing in a text box in the parent redraws the whole big list on every key press.
function Page() {
const [text, setText] = useState('');
return (
<div>
<input value={text} onChange={(e) => setText(e.target.value)} />
<BigList /> {/* renders again on every key press */}
</div>
);
}
Fix 1: move state down
The best and simplest fix needs no tools. Put the state in a smaller component that only contains the part that needs it:
function SearchBox() {
const [text, setText] = useState('');
return <input value={text} onChange={(e) => setText(e.target.value)} />;
}
function Page() {
return (
<div>
<SearchBox />
<BigList /> {/* no longer renders when typing */}
</div>
);
}
Now typing only redraws SearchBox. Another version of this trick is to pass the slow part in as children, since children made by the parent above are not redrawn when the small component's state changes.
Fix 2: React.memo
memo wraps a component so React skips rendering it when its props are the same as last time.
import { memo } from 'react';
const BigList = memo(function BigList({ items }) {
return (
<ul>
{items.map((item) => <li key={item.id}>{item.name}</li>)}
</ul>
);
});
React compares each prop with the old one using “is it the same value?”. For numbers and strings that is clear. For objects, arrays and functions, a new one is made on every render, so they are never the same as before, even if they look the same. That makes memo useless unless you keep those props steady. That is the job of the next two hooks.
Fix 3: useMemo for expensive work
useMemo remembers the result of a calculation, and recalculates only when its dependencies change.
function ProductList({ products, query }) {
const visible = useMemo(() => {
return products.filter((p) => p.name.toLowerCase().includes(query.toLowerCase()));
}, [products, query]);
return <ul>{visible.map((p) => <li key={p.id}>{p.name}</li>)}</ul>;
}
If another state in this component changes, visible is not recalculated, because products and query are the same. It is useful for really heavy work, such as filtering thousands of items, and for keeping an object or array steady so that a memo child does not redraw.
// Keep an object steady for a memo child
const style = useMemo(() => ({ color: theme === 'dark' ? 'white' : 'black' }), [theme]);
return <MemoChild style={style} />;
Fix 4: useCallback for steady functions
useCallback is the same as useMemo, but for functions. It gives the same function again unless a dependency changes:
const MemoRow = memo(function Row({ item, onRemove }) {
return <li>{item.name} <button onClick={() => onRemove(item.id)}>x</button></li>;
});
function List() {
const [items, setItems] = useState(initialItems);
const handleRemove = useCallback((id) => {
setItems((current) => current.filter((item) => item.id !== id));
}, []);
return <ul>{items.map((item) => <MemoRow key={item.id} item={item} onRemove={handleRemove} />)}</ul>;
}
Notice we used the function form of setItems, so the callback does not need items in its dependencies, and it stays the same forever. Without useCallback, handleRemove would be a new function every render, and memo on MemoRow would do nothing.
The React Compiler
React now has a compiler, a build tool that adds this kind of memoisation automatically. When it is turned on in your project, you will need to write useMemo, useCallback and memo much less. It is still good to know what they do, because you will meet them in existing code. Check the official React docs to see how to enable the compiler in your tool, as the steps change.
Fix 5: show less (virtualise long lists)
If a list has thousands of rows, even fast components are slow, because the browser has to make thousands of elements. The fix is to draw only the rows you can see, plus a few extra. This is called virtualisation or windowing. Libraries such as react-window and TanStack Virtual do it for you. For lists of a few hundred rows, you do not need it. Pagination (showing 20 at a time) is simpler and also good for users.
Fix 6: do not do work on every key press
Searching or filtering on every key press can be heavy. Two helpers:
- Debounce: wait until the person stops typing for a moment (see
useDebouncein lesson 17). - `useDeferredValue`: a React hook that lets the input stay quick while the slow list catches up a little later.
import { useDeferredValue } from 'react';
function Search({ items }) {
const [text, setText] = useState('');
const deferred = useDeferredValue(text);
const results = items.filter((i) => i.name.includes(deferred));
return (
<div>
<input value={text} onChange={(e) => setText(e.target.value)} />
<ul>{results.map((i) => <li key={i.id}>{i.name}</li>)}</ul>
</div>
);
}
The input updates at once. The list uses the slightly older deferred value, and React updates it when it has time, so typing never feels stuck.
Other quick wins
- Images: use modern sizes and formats, set
widthandheight, and addloading="lazy"for images below the fold. - Code splitting: load a page's code only when needed (next lesson).
- Keys: stable keys avoid recreating list items.
- Avoid heavy work while rendering: do not sort or parse big data in the body without
useMemo, if it is truly big. - Production build: always test speed with
npm run buildandnpm run preview. The development version is much slower on purpose.
A simple decision list
- Is the app really slow? If not, stop.
- Find the slow part with the Profiler.
- Can you move state down, or pass the slow part as children? Do that first.
- Is a heavy child redrawing with the same props? Add
memo, and make object and function props steady. - Is a calculation heavy? Use
useMemo. - Is a list very long? Paginate or virtualise.
Common mistakes
- Adding
memo,useMemoanduseCallbackeverywhere before measuring. - Wrapping a component in
memobut passing a new object or function each time, so it never skips. - Wrong dependency arrays in
useMemoanduseCallback, which give old values. - Measuring speed in the development build.
- Using the array index as a key in long lists that change.
Your task
- Make a page with a text box and a list of 2,000 items. Notice the lag when typing, and fix it by moving the state down.
- Wrap the list item in
memo, adduseCallbackfor a remove button, and check with the Profiler that rows no longer redraw. - Try
useDeferredValuefor a filter box. - Run the Profiler before and after each change and write down the difference.
How to check it worked
- Typing in the box does not make the whole list render in the Profiler.
- Removing one item redraws only the changed parts.
- You can say why
memoneeds steady props.
Summary
- Measure first with the Profiler. Many slow pages are slow because of big images and big code, not renders.
- The simplest fix is to move state down. Then use
memo,useMemoanduseCallbackwhere they really help. memoneeds steady props. Objects and functions must be kept steady withuseMemoanduseCallback.- For very long lists, paginate or virtualise. Use debounce or
useDeferredValuefor typing.