react · hooks · performance4 min
doing less in React
skipping renders with memo, skipping work with useMemo, skipping downloads with lazy, skipping DOM nodes with a virtual list, and the compiler that now handles the first two for you.
once you know why a component renders, making React faster mostly means making it do less: fewer renders, less work in each one, less code to download, fewer DOM nodes.
#skip renders: memo
a component renders when its parent does. memo adds a check: if every prop is the same as last time, skip it. "the same" means Object.is on each prop, a shallow check. {} === {} is false in JavaScript, so an object or a function created during the parent's render is never the same as last time.
that's the trap. I rendered a list of 1,000 items, changed the list's state, and counted how many items rendered again (React 19, in jsdom):
- no
memo: 1,000. memo, passingonSelect={(id) => setSelected(id)}to each item: still 1,000, because that arrow function is new on every render.memo, with the function wrapped inuseCallback: 0.
const Item = memo(function Item({ item, onSelect }) {
return <li onClick={() => onSelect(item.id)}>{item.title}</li>;
});
function List({ items }) {
const [selected, setSelected] = useState(null);
const select = useCallback((id) => setSelected(id), []);
return (
<ul>
{items.map((item) => (
<Item key={item.id} item={item} onSelect={select} />
))}
</ul>
);
}memo on its own did nothing here. it only works when every prop keeps its identity between renders, so it comes with useCallback for functions and useMemo for objects and arrays.
#skip work: useMemo
when a component computes something on every render, useMemo keeps the result until its inputs change:
function Cart({ items }) {
const total = useMemo(
() => items.reduce((sum, item) => sum + item.price, 0),
[items],
);
return <p>total: {total}</p>;
}the array at the end is what makes it work. leave it out and the function runs on every render, same as without useMemo.
summing a cart isn't expensive, though. most values are cheaper to compute again than to remember, and React's docs suggest timing it first: memoizing starts to make sense around 1ms or more.
#or let the compiler do it
since React Compiler 1.0, in October 2025, a build step can do this memoization for you. with it on, the inline arrow function above stops breaking memo, and you write far fewer useMemo and useCallback calls by hand.
#skip downloads: lazy
everything you import ends up in the bundle, including a settings panel most people never open. lazy puts a component in its own file, downloaded the first time it renders:
const Settings = lazy(() => import('./settings'));
function App() {
const [open, setOpen] = useState(false);
return (
<>
<button onClick={() => setOpen(true)}>settings</button>
{open && (
<Suspense fallback={<p>loading</p>}>
<Settings />
</Suspense>
)}
</>
);
}in Next.js, next/dynamic does the same thing, with a loading option where React uses Suspense. and to see what's in the bundle in the first place, @next/bundle-analyzer draws it as a map, with the biggest files in the biggest boxes.
#skip DOM nodes: virtual lists
a list of 10,000 rows puts 10,000 rows in the DOM, even though a screen only fits a few dozen. a virtual list renders the rows in view, plus a few above and below, and swaps them as you scroll. react-window does this. it comes from the author of react-virtualized, whose README points people to it as the lighter option.