React rendering performance is determined by how often components re-render and how expensive each render is, not by React itself being slow. React updates the UI in two phases: a render phase, where it calls your component functions and diffs the resulting element tree (reconciliation), and a commit phase, where only the actual differences are applied to the DOM. Exactly three things trigger a re-render: a state update, a parent re-rendering, or a subscribed context value changing. Most performance fixes therefore fall into a few categories: skipping renders with React.memo, useMemo, and useCallback; keeping references stable and splitting contexts; virtualizing long lists; deprioritizing non-urgent updates with useTransition and useDeferredValue; and letting the React Compiler apply memoization automatically. This guide explains the rendering model, shows how to measure real bottlenecks with the React DevTools Profiler, and walks through each fix with practical code examples.
How Do React Rendering and Reconciliation Actually Work?
React works in two distinct phases. The render phase calls your component functions to produce a tree of React elements (plain objects describing what the UI should look like). The commit phase takes the differences React computed and applies them to the DOM. The render phase is pure and can be paused, aborted, or restarted; the commit phase is where the browser actually changes.
Reconciliation is the diffing algorithm React runs during the render phase. When a component renders, React compares the newly returned element tree against the previous one. If an element is the same type in the same position, React reuses the underlying DOM node and only updates changed attributes. If the type differs, React tears down the old subtree and builds a new one. This is why swapping a component type unmounts everything below it, losing state, and why stable structure matters so much.
A re-render does not mean the DOM changed. Rendering is React calling your function and diffing the result; the DOM only touches what actually differs. The cost you feel is usually the render work itself, not the commit.
What Triggers a Re-Render in React?
There are exactly three things that cause a component to re-render, and understanding them removes most of the mystery.
A component re-renders when:
- Its own state changes via a useState or useReducer setter
- Its parent re-renders (by default, all children re-render regardless of whether their props changed)
- A context value it consumes changes
The second point is the one that surprises people. Passing the same props does not stop a child from re-rendering when the parent renders. React re-renders the whole subtree by default; props are only compared when you explicitly opt in with React.memo. Consider this deceptively simple example where typing in the input re-renders an expensive list on every keystroke.
function Dashboard() {
const [query, setQuery] = useState('');
return (
<div>
<input value={query} onChange={(e) => setQuery(e.target.value)} />
{/* Re-renders on every keystroke even though it does
not depend on `query` at all */}
<ExpensiveList items={items} />
</div>
);
}Notice that ExpensiveList receives the same items reference every time, yet it still re-renders because its parent did. The cheapest fix here is often not memoization at all but moving state down or lifting the expensive part up so it is not a child of the changing component.
// Isolate the state so only the input re-renders
function SearchBox() {
const [query, setQuery] = useState('');
return <input value={query} onChange={(e) => setQuery(e.target.value)} />;
}
function Dashboard() {
return (
<div>
<SearchBox />
<ExpensiveList items={items} />
</div>
);
}Measure First: The Profiler and why-did-you-render
Never optimize by guessing. The React DevTools Profiler records a session, then shows a flamegraph and a ranked chart of which components rendered, how long each took, and crucially why they rendered. Enable "Record why each component rendered" in the profiler settings and you will see labels like "props changed", "state changed", or "the parent rendered". That single setting turns speculation into evidence.
A practical profiling workflow:
- Open React DevTools, switch to the Profiler tab, and start recording
- Perform the slow interaction (typing, scrolling, opening a modal)
- Stop recording and read the ranked chart: wide bars are expensive, gray bars did not render
- Use the commit timeline to spot renders that fire far too often
- Confirm whether a fix helped by re-recording and comparing durations
For catching unnecessary re-renders during development, the why-did-you-render library logs to the console whenever a component re-rendered with props or state that were deeply equal to the previous values, which almost always signals a broken reference.
// wdyr.ts - imported once at the very top of your entry file
import React from 'react';
if (process.env.NODE_ENV === 'development') {
const whyDidYouRender = require('@welldone-software/why-did-you-render');
whyDidYouRender(React, {
trackAllPureComponents: true,
});
}
// Opt a specific component in
function ExpensiveList(props) {
/* ... */
}
ExpensiveList.whyDidYouRender = true;How Do You Use Memoization Correctly?
React gives you three memoization tools. React.memo memoizes a component so it skips re-rendering when its props are shallowly equal. useMemo caches the result of an expensive computation between renders. useCallback caches a function identity so it stays stable across renders. They are not free, and using them everywhere is a real anti-pattern, but applied to the right spot they eliminate whole render cascades.
React.memo
interface RowProps {
label: string;
onSelect: (id: string) => void;
}
// Skips re-rendering when label and onSelect are shallowly equal
const Row = React.memo(function Row({ label, onSelect }: RowProps) {
return <li onClick={() => onSelect(label)}>{label}</li>;
});React.memo only helps if the props are actually stable. If a parent passes a fresh object or inline arrow function on every render, the shallow comparison always fails and the memo does nothing but add overhead. That is where useMemo and useCallback come in: they keep those references stable so the memoized child can bail out.
useMemo and useCallback
function ProductList({ products, taxRate }: Props) {
const [selectedId, setSelectedId] = useState<string | null>(null);
// Recomputed only when products or taxRate change
const withTax = useMemo(
() => products.map((p) => ({ ...p, total: p.price * (1 + taxRate) })),
[products, taxRate]
);
// Stable identity across renders so memoized rows can bail out
const handleSelect = useCallback((id: string) => {
setSelectedId(id);
}, []);
return (
<ul>
{withTax.map((p) => (
<Row key={p.id} label={p.name} onSelect={handleSelect} />
))}
</ul>
);
}When NOT to Memoize
Memoization has a cost: React must store the previous value and dependencies and run a comparison on every render. For cheap computations and cheap components, that bookkeeping can be more expensive than just re-rendering. Wrapping every value in useMemo also clutters code and creates dependency arrays that quietly go stale and cause bugs.
Skip memoization when:
- The computation is trivial, such as string concatenation or a small map
- The component renders quickly and rarely (leaf components with a few DOM nodes)
- The memoized value depends on props that change on every render anyway
- You are memoizing to fix a problem better solved by moving state down or restructuring the tree
- The React Compiler is enabled and already handles memoization for you
Stable References and Context Splitting
Context is one of the biggest sources of accidental re-render storms. Every component that consumes a context re-renders whenever the context value changes by reference, regardless of which part of the value it actually reads. Two mistakes make this worse: creating a new value object inline on every render, and packing unrelated state into a single context.
// Anti-pattern: a new object every render forces every consumer
// to re-render, even ones that only read `user`.
function AppProvider({ children }) {
const [user, setUser] = useState(null);
const [theme, setTheme] = useState('dark');
return (
<AppContext.Provider value={{ user, setUser, theme, setTheme }}>
{children}
</AppContext.Provider>
);
}The fix is twofold. First, memoize the value so its reference only changes when the underlying data changes. Second, split unrelated concerns into separate contexts so a theme change never re-renders components that only care about the user.
const UserContext = createContext(null);
const ThemeContext = createContext(null);
function AppProvider({ children }) {
const [user, setUser] = useState(null);
const [theme, setTheme] = useState('dark');
// Each value is stable unless its own state changes
const userValue = useMemo(() => ({ user, setUser }), [user]);
const themeValue = useMemo(() => ({ theme, setTheme }), [theme]);
return (
<UserContext.Provider value={userValue}>
<ThemeContext.Provider value={themeValue}>
{children}
</ThemeContext.Provider>
</UserContext.Provider>
);
}A common further refinement is to separate the state value from the setter dispatch into two contexts. Components that only dispatch actions never re-render when the state changes, because the dispatch function is stable for the lifetime of the provider.
List Keys: Small Prop, Big Consequences
Keys tell React how to match elements between renders during reconciliation. A stable, unique key lets React reuse DOM nodes and component state when items reorder. Using the array index as a key is fine only for static lists that never reorder, insert, or delete; otherwise it causes React to associate the wrong state with the wrong item, producing subtle bugs and wasted work.
// Wrong for dynamic lists: index shifts when items are inserted
{todos.map((todo, index) => <TodoItem key={index} todo={todo} />)}
// Right: a stable identity that follows the item
{todos.map((todo) => <TodoItem key={todo.id} todo={todo} />)}When Should You Virtualize Long Lists?
No amount of memoization saves you from rendering ten thousand DOM nodes at once. Virtualization renders only the rows currently visible in the viewport plus a small overscan buffer, recycling nodes as the user scrolls. Libraries like @tanstack/react-virtual give you the measurements and let you position rows absolutely.
import { useVirtualizer } from '@tanstack/react-virtual';
function BigList({ rows }: { rows: string[] }) {
const parentRef = useRef<HTMLDivElement>(null);
const virtualizer = useVirtualizer({
count: rows.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 40,
overscan: 8,
});
return (
<div ref={parentRef} style={{ height: 600, overflow: 'auto' }}>
<div style={{ height: virtualizer.getTotalSize(), position: 'relative' }}>
{virtualizer.getVirtualItems().map((item) => (
<div
key={item.key}
style={{
position: 'absolute',
top: 0,
transform: `translateY(${item.start}px)`,
height: item.size,
}}
>
{rows[item.index]}
</div>
))}
</div>
</div>
);
}Concurrent Features: useTransition and useDeferredValue
React 18 introduced concurrent rendering, which lets React interrupt a long render to handle a more urgent update. The two hooks you will reach for are useTransition and useDeferredValue. Both let you mark work as low priority so that typing, clicking, and other urgent interactions stay responsive while expensive updates catch up.
useTransition wraps a state update that can be interrupted. The input update stays urgent and instant, while filtering a large list is marked as a transition that React can pause if the user keeps typing. isPending gives you a flag to show a subtle loading state.
function SearchableList({ items }: { items: Item[] }) {
const [query, setQuery] = useState('');
const [results, setResults] = useState(items);
const [isPending, startTransition] = useTransition();
function handleChange(e: React.ChangeEvent<HTMLInputElement>) {
const value = e.target.value;
setQuery(value); // urgent: keeps the input responsive
startTransition(() => {
// low priority: can be interrupted by the next keystroke
setResults(items.filter((i) => i.name.includes(value)));
});
}
return (
<>
<input value={query} onChange={handleChange} />
<ul style={{ opacity: isPending ? 0.6 : 1 }}>
{results.map((r) => <li key={r.id}>{r.name}</li>)}
</ul>
</>
);
}useDeferredValue is the derived-value counterpart. Instead of wrapping a setter, you pass it a value and get back a version that lags behind during heavy work. It is ideal when the expensive consumer is a child you do not control the updates of.
function ResultsView({ query }: { query: string }) {
const deferredQuery = useDeferredValue(query);
// ExpensiveResults renders against the deferred value, so typing
// stays smooth while results trail slightly behind.
return <ExpensiveResults query={deferredQuery} />;
}The React Compiler: Automatic Memoization
The React Compiler is a build-time tool that analyzes your components and automatically inserts the equivalent of useMemo, useCallback, and React.memo where they are safe and beneficial. It understands React's rules of hooks and only memoizes when it can prove your code follows them. The goal is that you write plain, readable components and get fine-grained memoization for free.
// babel.config.js
module.exports = {
plugins: [
['babel-plugin-react-compiler', {
// target the React version you run
target: '19',
}],
],
};What changes in practice: much of the manual useMemo and useCallback you write today becomes unnecessary, and the compiler often memoizes at a finer granularity than a human would bother to. It does not replace architectural fixes, though. Context splitting, moving state down, virtualization, and concurrent features are still your job. The compiler also relies on your code being idiomatic; components with side effects during render or mutations of props can opt themselves out. Treat it as a powerful default that removes memoization boilerplate, not as a license to ignore how rendering works.
Code-Splitting with lazy and Suspense
Rendering performance is not only about re-renders; it is also about how much JavaScript the browser has to parse and execute before your app is interactive. React.lazy defers loading a component's code until it is actually rendered, and Suspense provides a fallback while that chunk downloads. This is the single most effective way to shrink an initial bundle.
import { lazy, Suspense } from 'react';
// The chart bundle only downloads when the route renders
const AnalyticsDashboard = lazy(() => import('./AnalyticsDashboard'));
function App() {
return (
<Suspense fallback={<Spinner />}>
<AnalyticsDashboard />
</Suspense>
);
}Split at natural boundaries: routes, modals, tabs, and heavy third-party widgets like editors or charting libraries. Avoid splitting tiny components, because each chunk carries network overhead and a suspense fallback flash can hurt perceived performance more than it helps.
Common Anti-Patterns to Avoid
Watch out for these recurring mistakes:
- Creating objects, arrays, or functions inline in JSX props passed to memoized children, which defeats the memoization
- Defining a component inside another component, which recreates its type on every render and remounts the whole subtree, destroying its state
- Sprinkling useMemo and useCallback everywhere as a reflex instead of measuring first
- Putting all app state into one giant context so unrelated updates re-render the entire tree
- Using array index as a key in lists that reorder, insert, or delete
- Doing expensive work (sorting, filtering, formatting) directly in the render body without memoization or a transition
- Storing derived state in useState and syncing it with useEffect instead of computing it during render
// Anti-pattern: new array + inline function every render
<List
data={items.filter((i) => i.active)} // fresh array each render
onClick={(id) => select(id)} // fresh function each render
/>
// Better: stabilize both
const activeItems = useMemo(() => items.filter((i) => i.active), [items]);
const onClick = useCallback((id: string) => select(id), [select]);
<List data={activeItems} onClick={onClick} />Key Takeaways
The essentials to remember:
- A re-render is React calling your function and diffing the result; the DOM only updates what actually differs
- Re-renders come from three sources: own state, a parent rendering, or a consumed context changing
- Always profile before optimizing; use the DevTools Profiler with "why each component rendered" and why-did-you-render
- React.memo, useMemo, and useCallback only help when references are stable and the work is genuinely expensive
- Split contexts by concern and memoize their values to prevent re-render storms
- Use stable keys and virtualize long lists instead of memoizing thousands of rows
- Reach for useTransition and useDeferredValue to keep interactions responsive under heavy updates
- The React Compiler automates memoization, but architecture, code-splitting, and concurrency are still your responsibility
- Ship less JavaScript with lazy and Suspense, and split at routes, modals, and heavy widgets
Fast React apps are rarely the result of one clever trick. They come from understanding the render model, measuring real interactions, and applying the right tool to the right bottleneck. Start with the Profiler, fix the structural problems first, and let memoization and the compiler handle the rest.
