Most React apps are fast enough most of the time. When they are not, the cause is usually one of a handful of patterns: too much work during render, too many components re-rendering, too much JavaScript shipped, or too many DOM nodes. This guide covers eight practical fixes, but the first rule matters more than any of them: measure before you optimise.
1. Measure with the right tools
Use the React Developer Tools Profiler to record an interaction and see which components rendered and how long they took. Enable “Highlight updates when components render” to spot unexpected re-renders. For user-facing metrics, check Core Web Vitals — especially Interaction to Next Paint (INP) for responsiveness and Largest Contentful Paint (LCP) for loading — using Lighthouse in development and field data from real users where available.
Write down a baseline number. Without one, you cannot tell whether a change helped.
2. Keep state close to where it is used
When state lives high in the tree, every update re-renders everything below it. Moving state down is often the single most effective optimisation, and it requires no memoisation at all.
// Before: typing in the search box re-renders the whole page.
function Page() {
const [query, setQuery] = useState("");
return (
<>
<SearchBox value={query} onChange={setQuery} />
<ExpensiveDashboard />
</>
);
}
// After: only SearchBox re-renders while typing.
function Page() {
return (
<>
<SearchBox />
<ExpensiveDashboard />
</>
);
}
3. Memoise expensive components with memo
React.memo skips re-rendering a component when its props are shallowly equal to last time. Use it on components that are expensive to render and receive stable props.
const ArticleRow = memo(function ArticleRow({ article, onSelect }) {
return (
<li onClick={() => onSelect(article.id)}>
{article.title}
</li>
);
});
Memoisation only works if props are stable. Which leads to the next point.
4. Stabilise callbacks and derived values
Functions and objects created during render are new every time, so they break memo. useCallback and useMemo keep them stable between renders when their dependencies have not changed.
function ArticleList({ articles, query }) {
const visible = useMemo(
() => articles.filter((a) => a.title.toLowerCase().includes(query)),
[articles, query]
);
const handleSelect = useCallback((id) => navigate(`/articles/${id}`), []);
return visible.map((a) => (
<ArticleRow key={a.id} article={a} onSelect={handleSelect} />
));
}
Do not wrap everything in useMemo. It has a cost too. Use it for genuinely expensive calculations and for values passed to memoised children. If your project uses the React Compiler, much of this is handled automatically — check your setup before adding manual memoisation.
5. Use stable, meaningful keys
Keys tell React which list item is which. Using the array index as a key causes unnecessary work — and subtle bugs with inputs — when items are inserted, removed or reordered. Use a stable ID from your data.
6. Virtualise long lists
Rendering 5,000 rows creates 5,000 sets of DOM nodes, even though only 20 are visible. Virtualisation renders only the rows in the viewport. Libraries such as TanStack Virtual or react-window handle the maths for you.
import { useVirtualizer } from "@tanstack/react-virtual";
function VirtualList({ rows }) {
const parentRef = useRef(null);
const virtualizer = useVirtualizer({
count: rows.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 56,
});
return (
<div ref={parentRef} style={{ height: 480, overflow: "auto" }}>
<div style={{ height: virtualizer.getTotalSize(), position: "relative" }}>
{virtualizer.getVirtualItems().map((item) => (
<div
key={rows[item.index].id}
style={{ position: "absolute", top: 0, transform: `translateY(${item.start}px)`, height: item.size }}
>
{rows[item.index].title}
</div>
))}
</div>
</div>
);
}
7. Keep typing responsive with transitions
Some updates are urgent (the characters appearing in an input) and some are not (filtering a large list). useTransition and useDeferredValue let React keep urgent updates responsive while rendering the expensive part in the background.
function Search({ items }) {
const [query, setQuery] = useState("");
const deferredQuery = useDeferredValue(query);
return (
<>
<input value={query} onChange={(e) => setQuery(e.target.value)} />
<Results items={items} query={deferredQuery} />
</>
);
}
This directly helps INP, because the browser can paint the next frame after a keystroke without waiting for the whole list.
8. Ship less JavaScript
The fastest code is code the browser never downloads.
- Split routes and heavy widgets with
lazy()andSuspense, so a charting library only loads on the page that uses it. - Check your bundle with an analyzer and replace large dependencies that you use for one function.
- Optimise images: correct dimensions, modern formats and lazy loading below the fold.
const ReportsChart = lazy(() => import("./ReportsChart"));
function ReportsPage() {
return (
<Suspense fallback={<ChartSkeleton />}>
<ReportsChart />
</Suspense>
);
}
A quick decision table
| Symptom | Likely fix |
|---|---|
| Whole page re-renders on each keystroke | Move state down, useDeferredValue |
| A heavy child re-renders with unchanged data | memo + stable props |
| Scrolling a long list stutters | Virtualisation |
| Slow first load | Code splitting, smaller bundle, image optimisation |
| Laggy clicks and inputs (poor INP) | Transitions, less work per interaction |
Performance work is iterative: measure, change one thing, measure again. Small, targeted fixes beat sweeping rewrites almost every time.
Comments