React applications rarely start slow. They get slow gradually — a context provider here, an unmemoized list there — until someone opens the Profiler and finds a component re-rendering 400 times on a single keystroke.
These are the five optimisations that actually move the needle, in the order you should reach for them. One warning before we start, because it's the most common mistake in this topic: measure before you optimise. Memoizing every component in your app makes it slower, not faster, because memoization itself costs memory and comparison time.
0. Measure First: The React Profiler
Install React DevTools, open the Profiler tab, hit record, and interact with the slow part of your app. You get a flame graph showing exactly which components rendered and how long each took.
Turn on "Highlight updates when components render" in DevTools settings too. If typing in a search box makes your entire page flash, you have a state-placement problem, and no amount of React.memo will fix it properly.
For production monitoring, the <Profiler> component reports real user timings:
import { Profiler } from 'react';
function onRender(id, phase, actualDuration) {
if (actualDuration > 16) {
console.warn(`${id} took ${actualDuration.toFixed(1)}ms (${phase})`);
}
}
<Profiler id="ProductList" onRender={onRender}>
<ProductList items={items} />
</Profiler>
16ms is the budget for one frame at 60fps. Anything slower drops frames.
1. Let the React Compiler Do the Memoization
This is the biggest change in React performance work. The React Compiler (shipped alongside React 19) analyses your components at build time and inserts memoization automatically. In most codebases it removes the need to write useMemo, useCallback, and React.memo by hand.
Install it as a Babel plugin:
npm install --save-dev babel-plugin-react-compiler
// babel.config.js
module.exports = {
plugins: [
['babel-plugin-react-compiler', { target: '19' }],
],
};
The compiler only optimises components that follow the Rules of React. Run the ESLint plugin first to find the ones that don't:
npm install --save-dev eslint-plugin-react-hooks@latest
If you're on React 19 and starting fresh, enable the compiler and skip most of section 2. If you're on React 18 or have a large legacy codebase, manual memoization is still your tool — read on.
2. Manual Memoization: memo, useMemo, useCallback
React.memo skips re-rendering a component when its props haven't changed by shallow comparison:
import { memo } from 'react';
const ProductRow = memo(function ProductRow({ product, onSelect }) {
console.log('rendering', product.id);
return (
<li onClick={() => onSelect(product.id)}>
{product.name} — ${product.price}
</li>
);
});
export default ProductRow;
Here is the catch that makes most memo usage useless. If the parent passes a new object or function on every render, the shallow comparison fails every time and memo does nothing but add overhead:
// BROKEN — a new function identity on every parent render,
// so memo() never prevents anything
<ProductRow product={p} onSelect={(id) => setSelected(id)} />
Stabilise the function with useCallback, and any derived object with useMemo:
import { useCallback, useMemo, useState } from 'react';
function ProductList({ products, query }) {
const [selected, setSelected] = useState(null);
// Stable identity across renders — memo() can now do its job
const handleSelect = useCallback((id) => {
setSelected(id);
}, []);
// Only re-filters when products or query actually change
const visible = useMemo(
() => products.filter((p) => p.name.includes(query)),
[products, query]
);
return (
<ul>
{visible.map((p) => (
<ProductRow key={p.id} product={p} onSelect={handleSelect} />
))}
</ul>
);
}
Use useMemo for genuinely expensive work — filtering thousands of rows, parsing, heavy maths. Wrapping a + b in useMemo costs more than it saves.
3. Code Splitting with lazy and Suspense
Every route and heavy dependency shipped in your initial bundle delays first paint. lazy() defers loading until the component is actually rendered:
import { lazy, Suspense } from 'react';
// Not downloaded until the user opens the dashboard
const Dashboard = lazy(() => import('./pages/Dashboard'));
const ChartEditor = lazy(() => import('./pages/ChartEditor'));
function App() {
return (
<Suspense fallback={<PageSkeleton />}>
<Routes>
<Route path="/dashboard" element={<Dashboard />} />
<Route path="/editor" element={<ChartEditor />} />
</Routes>
</Suspense>
);
}
Split at route boundaries first — that's where the payoff is largest. Then split individually heavy components: rich text editors, charting libraries, date pickers, map widgets. A single charting library can be 300KB on its own.
Use a real skeleton as the fallback rather than a spinner. Swapping a spinner for content causes layout shift, which hurts your Cumulative Layout Shift score.
4. Virtualize Long Lists
Rendering 5,000 rows creates 5,000 DOM nodes, and the browser has to lay out and paint every one. Virtualization renders only what's visible plus a small buffer:
npm install react-window
import { FixedSizeList } from 'react-window';
function Row({ index, style, data }) {
const product = data[index];
return (
<div style={style} className="row">
{product.name} — ${product.price}
</div>
);
}
function ProductTable({ products }) {
return (
<FixedSizeList
height={600}
width="100%"
itemCount={products.length}
itemSize={48}
itemData={products}
>
{Row}
</FixedSizeList>
);
}
That style prop is mandatory — it carries the absolute positioning that puts each row in the right place. Forgetting to spread it is the number one reason people's first virtualized list renders as a stack of overlapping rows.
For rows of differing heights use VariableSizeList, or @tanstack/react-virtual if you need grid layouts and dynamic measurement.
5. Fix State Placement and Context Re-renders
This is the optimisation people reach for last and should reach for first, because it addresses the cause rather than the symptom.
Colocate state. State lives too high in most component trees. If only one subtree reads a value, move the state into that subtree:
// BEFORE — typing re-renders Header, Sidebar and Footer
function App() {
const [query, setQuery] = useState('');
return (
<>
<Header />
<Sidebar />
<SearchBox value={query} onChange={setQuery} />
<Results query={query} />
<Footer />
</>
);
}
// AFTER — typing only re-renders SearchPanel's subtree
function SearchPanel() {
const [query, setQuery] = useState('');
return (
<>
<SearchBox value={query} onChange={setQuery} />
<Results query={query} />
</>
);
}
Split your contexts. Every consumer of a context re-renders when any part of its value changes. A single "AppContext" holding user, theme, and cart means changing the cart re-renders every component reading the theme. Split them by update frequency:
// One context per concern, not one context for everything
<ThemeContext.Provider value={theme}> {/* changes rarely */}
<UserContext.Provider value={user}> {/* changes on login */}
<CartContext.Provider value={cart}> {/* changes constantly */}
{children}
</CartContext.Provider>
</UserContext.Provider>
</ThemeContext.Provider>
And always memoize a context value that's an object literal, or you create a new identity on every provider render:
const value = useMemo(() => ({ user, logout }), [user, logout]);
return <UserContext.Provider value={value}>{children}</UserContext.Provider>;
What About Arrow Functions in JSX?
You'll see advice saying never write onClick={() => doThing()} in JSX. This is overstated. Creating a function is cheap, and for a plain DOM element like <button> it costs essentially nothing.
It matters in exactly one case: when the receiving component is wrapped in React.memo, because the new identity defeats the memo comparison. That's the situation useCallback exists for. Everywhere else, inline arrows are fine and far more readable.
Conclusion
If you take one thing from this: profile first, fix state placement second, and memoize last. Most React performance problems are really state-architecture problems wearing a disguise, and moving state closer to where it's used fixes them more thoroughly than any amount of memo.
If you're on React 19, turn the compiler on and let it handle the memoization you'd otherwise write by hand. Then spend your effort on the things it can't do for you — code splitting, virtualization, and sensible context boundaries.
For a broader look at front-end speed beyond React itself, see Mastering Frontend Performance Optimization.

Comments
Post a Comment