The Optimization Trap
Every React performance article mentions useMemo and useCallback. They're real tools, but they're also overused to the point of becoming cargo cult. Adding useMemo to every computed value and useCallback to every function doesn't make your app faster — it adds memory overhead and makes your code harder to read.
Let's talk about what actually makes a difference.
Measure First, Always
Before touching any code, open Chrome DevTools → Performance tab and record the problem. React DevTools Profiler shows you which components re-render and why.
The question is never "is this component re-rendering?" — every render is cheap until it isn't. The question is: "Is an expensive operation happening inside a render that doesn't need to?"
The Biggest Win: Move State Down
The single most impactful performance technique in React is placing state as close to where it's used as possible.
// ❌ Bad: top-level state causes the entire tree to re-render on every keypress
function Page() {
const [query, setQuery] = useState("");
return (
<>
<Header />
<HeavyDataGrid />
<SearchBar query={query} onChange={setQuery} />
</>
);
}
// ✅ Good: state is isolated, only SearchBar re-renders
function Page() {
return (
<>
<Header />
<HeavyDataGrid />
<SearchBar />
</>
);
}
function SearchBar() {
const [query, setQuery] = useState("");
return <input value={query} onChange={(e) => setQuery(e.target.value)} />;
}This is free — no memoization, no extra hooks.
Component Memoization: When It's Actually Worth It
React.memo is worth it when:
1. A component renders frequently (parent state changes often).
2. The component is expensive to render (large lists, complex calculations, deep trees).
3. Props are stable (primitive values or memoized references).
If a component renders quickly, wrapping it in React.memo costs more (shallow comparison on every render) than it saves.
Virtualize Long Lists
If you're rendering more than ~100 items in a list without virtualization, you're leaving performance on the table. Every list item is a DOM node, and DOM operations are expensive.
`@tanstack/react-virtual` is my go-to — it renders only the visible items plus a small buffer:
const rowVirtualizer = useVirtualizer({
count: items.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 56, // row height in px
});
return (
<div ref={parentRef} style={{ height: "600px", overflow: "auto" }}>
<div style={{ height: `${rowVirtualizer.getTotalSize()}px`, position: "relative" }}>
{rowVirtualizer.getVirtualItems().map((virtualRow) => (
<div
key={virtualRow.index}
style={{ position: "absolute", top: virtualRow.start, height: `${virtualRow.size}px` }}
>
{items[virtualRow.index].name}
</div>
))}
</div>
</div>
);A list of 10,000 items renders as fast as a list of 20.
Code Splitting with `React.lazy`
Bundle size directly affects initial load time. Split heavy routes or components behind a lazy import:
const HeavyChart = React.lazy(() => import("./HeavyChart"));
function Dashboard() {
return (
<Suspense fallback={<Skeleton />}>
<HeavyChart />
</Suspense>
);
}The chart code only loads when the component mounts. For Next.js, next/dynamic does the same thing.
Expensive Calculations: When `useMemo` Is Correct
// ✅ useMemo makes sense here: sorting 50,000 items is genuinely expensive
const sortedProducts = useMemo(
() => [...products].sort((a, b) => a.price - b.price),
[products]
);
// ❌ useMemo is pointless here: this runs in microseconds
const fullName = useMemo(
() => `${user.firstName} ${user.lastName}`,
[user.firstName, user.lastName]
);The rule of thumb: if you can't measure the slowness without DevTools, don't memoize it.
Summary
In order of impact:
1. Move state down — free, huge wins, first thing to try.
2. Virtualize long lists — if you have them, this is non-negotiable.
3. Code split heavy components/routes — reduce initial bundle.
4. `React.memo` — only on expensive, frequently-updating components.
5. `useMemo` / `useCallback` — only when you can measure the difference.
