React Performance: Profiling, Memoization, and Lists
A practical playbook for React performance. Profile with React DevTools, apply React.memo deliberately, fix key issues, virtualize long lists, and avoid premature optimization.
What you'll learn
- ✓How to use the React Profiler to find real bottlenecks
- ✓When React.memo helps and when it is noise
- ✓How the key prop affects list reconciliation
- ✓When and how to virtualize long lists
- ✓How to avoid optimizing problems you do not have
Prerequisites
- •Working knowledge of React hooks
- •Some experience with props and state
Performance work in React is mostly the discipline of not doing performance work. The default behavior is fast enough for the vast majority of apps. When a screen does feel slow, the right move is to measure, isolate the cause, and apply the smallest fix. This article is a tour of the tools and techniques in roughly the order you should reach for them.
Profile Before You Tune
Open React Developer Tools and switch to the Profiler tab. Hit record, perform the slow interaction (typing, opening a panel, navigating), and stop. You will see a flamegraph of every commit during that window.
Look for:
- Commits that take a long time. Anything over 16 ms during interaction is suspicious; anything over 50 ms is felt.
- Components that render frequently when they should not.
- The “why did this render” panel, which tells you whether props or state changed.
This is your starting point. Do not memoize a thing until you have seen its render cost in the Profiler. Otherwise you are guessing.
Avoiding Re-Renders with React.memo
React.memo wraps a component so that React skips re-rendering it if its props are shallowly equal to last time.
type Props = { user: User; onSelect: (id: string) => void };
const UserRow = React.memo(function UserRow({ user, onSelect }: Props) {
return (
<li onClick={() => onSelect(user.id)}>
{user.name}
</li>
);
});
The hidden cost: every render now does a shallow prop comparison, and an object literal or inline function passed as a prop will break the equality check.
// Breaks memoization: a fresh object every render
<UserRow user={u} onSelect={(id) => track(id)} />
// Stable callback: memoization can take effect
const onSelect = useCallback((id: string) => track(id), []);
<UserRow user={u} onSelect={onSelect} />
Apply React.memo to components that are rendered many times (rows in a list, items in a grid) or that contain meaningful render work, and only after the Profiler confirms re-renders are an actual cost. For a deeper look, see useMemo and useCallback.
State Colocation
A surprisingly effective optimization is to move state down. If a piece of state only matters to one subtree, it should live there, not at the top of the app.
// Before: every keystroke re-renders the whole page
function Page() {
const [query, setQuery] = useState('');
return (
<>
<Header />
<SearchInput value={query} onChange={setQuery} />
<Results query={query} />
<Footer />
</>
);
}
// After: state moves into the consumer
function Page() {
return (
<>
<Header />
<Search />
<Footer />
</>
);
}
function Search() {
const [query, setQuery] = useState('');
return (
<>
<SearchInput value={query} onChange={setQuery} />
<Results query={query} />
</>
);
}
Header and Footer no longer re-render on each keystroke. No memoization needed. This is almost always cleaner than wrapping components in React.memo to avoid the same renders.
The Key Prop and List Reconciliation
React uses the key prop to match items across renders. Wrong keys lead to unnecessary unmounts, lost state, and slow updates.
Bad: using array index when the list reorders.
{items.map((item, i) => (
<Card key={i} item={item} />
))}
If a row moves, every key changes from that point down, so React tears down and rebuilds those components. Any local state (open menus, input focus) is lost.
Good: a stable, unique id.
{items.map((item) => (
<Card key={item.id} item={item} />
))}
Only the rows that truly changed re-render. Local state is preserved across reorders.
Index keys are acceptable for static lists that never reorder or get items inserted in the middle, but the moment that assumption breaks, switch to an id.
Virtualizing Long Lists
Rendering 10,000 rows is expensive even with perfect memoization, because the DOM must hold all those nodes. The fix is virtualization: render only the rows currently in the viewport, plus a small buffer.
Two popular libraries are react-window and @tanstack/react-virtual. Here is a small example with @tanstack/react-virtual.
import { useVirtualizer } from '@tanstack/react-virtual';
import { useRef } from 'react';
function Rows({ items }: { items: Item[] }) {
const parentRef = useRef<HTMLDivElement>(null);
const virtualizer = useVirtualizer({
count: items.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 40,
overscan: 5,
});
return (
<div ref={parentRef} style={{ height: 400, overflow: 'auto' }}>
<div style={{ height: virtualizer.getTotalSize(), position: 'relative' }}>
{virtualizer.getVirtualItems().map((row) => (
<div
key={row.key}
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
transform: `translateY(${row.start}px)`,
height: row.size,
}}
>
{items[row.index].name}
</div>
))}
</div>
</div>
);
}
The browser only sees the rows on screen. Scrolling stays smooth even with hundreds of thousands of items. The cost: you give up native browser features like Ctrl-F search across the full list, since most rows are not in the DOM.
Reach for virtualization when a list has more than a few hundred items, and especially when each row is non-trivial.
Splitting Heavy Work
Sometimes the bottleneck is not React but the work running inside a render or effect. A few patterns help.
- Move heavy synchronous work off the main thread by debouncing or throttling it.
- For non-urgent updates, use
useDeferredValueso React can interrupt them when the user types. - For genuinely long computations (parsing large files, image processing), use a Web Worker so the UI thread stays responsive.
import { useDeferredValue } from 'react';
function SearchResults({ query }: { query: string }) {
const deferred = useDeferredValue(query);
const results = useMemo(() => filter(items, deferred), [deferred]);
return <List items={results} />;
}
While the user is still typing, deferred lags behind. React keeps the input responsive and recomputes results in the background.
Avoiding Premature Optimization
Memoization, virtualization, and deferral all add complexity. They introduce subtle bugs (stale closures, lost focus, broken accessibility) and make code harder to follow. They are not free. The mistake to avoid is reaching for them on instinct, before you know there is a problem.
A sensible workflow:
- Build the feature with the simplest code you can write.
- Use it in development with realistic data volumes.
- If it feels slow, open the Profiler.
- Identify the single most expensive commit or component.
- Apply the smallest fix: split state, fix keys, then memoize, then virtualize.
- Re-measure.
If you cannot demonstrate an improvement in the Profiler, your optimization is decoration. Revert it.
Useful Patterns Beyond Hooks
A few non-hook patterns also pay off.
- Pass primitives instead of objects when you can.
<Row id={item.id} name={item.name} />instead of<Row item={item} />avoids changing identity for unrelated fields. - Avoid context that updates frequently. Context value changes re-render every consumer. For a fast-moving value, prefer a focused store. For low-traffic global data, the Context API works well.
- Extract pure computation out of components. A function that does not depend on props or state belongs in a module, not in a render.
Wrap up
React performance is not magic and is rarely about clever tricks. Profile first, fix the largest single source of work, then re-measure. Use React.memo deliberately, give lists stable keys, virtualize when lists get long, and defer non-urgent updates when typing has to feel instant. Most of all, resist the urge to optimize what you have not measured. Boring code that profiles well will always beat clever code that does not.