React Performance Optimization: Profiling, Memoization & Concurrent Features
Identify and fix real React performance bottlenecks ā using the Profiler, fixing unnecessary re-renders, virtualization for large lists, Web Vitals, and React 18 concurrent features.
Measure Before You Optimize
The most common React performance mistake is applying optimization hooks everywhere without profiling first. useMemo and useCallback add overhead ā they only help when the saved computation or re-render cost outweighs the hook cost.
Profile first. Optimize second. Measure again.
React DevTools Profiler
The Profiler records which components rendered, why, and for how long.
- Install React DevTools browser extension
- Open DevTools ā Profiler tab
- Click Record ā Interact with your app ā Stop recording
- Inspect the flame chart
Flame chart reading:
- Wide bars = components that took long to render
- Yellow bars = components that rendered but didn't need to
- Gray bars = components that were bailed out (skipped re-render)Programmatic Profiling
import { Profiler } from "react";
function onRenderCallback(
id, // component name passed to Profiler
phase, // "mount" or "update"
actualDuration, // Time spent rendering this update
baseDuration, // Estimated time without memoization
startTime,
commitTime
) {
if (actualDuration > 16) { // Flag renders > 1 frame (16ms)
console.warn(`Slow render in ${id}: ${actualDuration.toFixed(2)}ms (${phase})`);
// Send to analytics/monitoring
analytics.track("slow_render", { component: id, duration: actualDuration });
}
}
function App() {
return (
<Profiler id="App" onRender={onRenderCallback}>
<Dashboard />
</Profiler>
);
}Understanding Re-Renders
A component re-renders when:
- Its own state changes
- Its parent re-renders (even if props didn't change)
- A context it consumes changes
// Demonstration: why parent re-renders cause child re-renders
function Parent() {
const [count, setCount] = useState(0);
return (
<div>
<button onClick={() => setCount((c) => c + 1)}>
Parent Count: {count}
</button>
{/* Child re-renders every time Parent does ā even though it has no props */}
<Child />
</div>
);
}
function Child() {
console.log("Child rendered");
return <div>I'm a child</div>;
}
// Fix: wrap in React.memo
const Child = React.memo(function Child() {
console.log("Child rendered");
return <div>I'm a child</div>;
});The Re-render Audit Pattern
// Temporary debugging: log every render with why
function useWhyDidYouUpdate(name, props) {
const previousProps = useRef({});
useEffect(() => {
if (previousProps.current) {
const changedProps = Object.entries(props).reduce((acc, [key, value]) => {
if (previousProps.current[key] !== value) {
acc[key] = {
from: previousProps.current[key],
to: value,
};
}
return acc;
}, {});
if (Object.keys(changedProps).length > 0) {
console.log(`[${name}] Re-rendered due to:`, changedProps);
}
}
previousProps.current = props;
});
}
// Usage: wrap any component temporarily
function ProductCard({ product, onAddToCart, isSelected }) {
useWhyDidYouUpdate("ProductCard", { product, onAddToCart, isSelected });
// ...
}Fixing Unnecessary Re-Renders
Pattern 1: Unstable Object/Array Props
// ā New object reference every render ā breaks React.memo
function App() {
return (
<MemoizedChart
config={{ color: "blue", size: "large" }} // New object every render!
data={[1, 2, 3]} // New array every render!
/>
);
}
// ā
Stable references
const CHART_CONFIG = { color: "blue", size: "large" };
const DEFAULT_DATA = [1, 2, 3];
function App() {
return <MemoizedChart config={CHART_CONFIG} data={DEFAULT_DATA} />;
}
// Or useMemo for dynamic values
function App({ userId }) {
const config = useMemo(
() => ({ color: "blue", size: "large", userId }),
[userId]
);
return <MemoizedChart config={config} />;
}Pattern 2: Context Causing Global Re-renders
// ā Any user state change re-renders EVERYTHING consuming this context
const AppContext = createContext();
function AppProvider({ children }) {
const [user, setUser] = useState(null);
const [notifications, setNotifications] = useState([]);
const [theme, setTheme] = useState("light");
return (
<AppContext.Provider value={{ user, notifications, theme, setUser, setTheme }}>
{children}
</AppContext.Provider>
);
}
// ā
Split by update frequency
function AppProviders({ children }) {
return (
<ThemeProvider> {/* Changes rarely */}
<AuthProvider> {/* Changes on login/logout */}
<NotificationProvider> {/* Changes frequently */}
{children}
</NotificationProvider>
</AuthProvider>
</ThemeProvider>
);
}Pattern 3: State Too High in the Tree
// ā Filter state in parent causes full list re-render
function ProductPage() {
const [filter, setFilter] = useState("");
const [products, setProducts] = useState([]);
return (
<>
<FilterBar filter={filter} onChange={setFilter} />
<ProductGrid products={products} filter={filter} />
</>
);
}
// ā
Move local state down ā only FilterBar re-renders on input
function ProductPage() {
const [products] = useState([]);
return (
<>
<FilterBar products={products} /> {/* Manages its own filter state */}
</>
);
}
function FilterBar({ products }) {
const [filter, setFilter] = useState("");
const filtered = useMemo(
() => products.filter((p) => p.name.includes(filter)),
[products, filter]
);
return (
<>
<input value={filter} onChange={(e) => setFilter(e.target.value)} />
<ProductGrid products={filtered} />
</>
);
}Virtualization: Rendering Large Lists
Rendering 10,000 rows causes 10,000 DOM nodes. Virtualization renders only visible rows.
npm install @tanstack/react-virtualimport { useVirtualizer } from "@tanstack/react-virtual";
function VirtualizedList({ items }) {
const parentRef = useRef(null);
const virtualizer = useVirtualizer({
count: items.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 72, // Estimated row height in px
overscan: 5, // Render 5 extra rows above/below viewport
});
return (
<div
ref={parentRef}
style={{ height: "600px", overflow: "auto" }}
>
{/* Total scroll height = number of items Ć estimated row height */}
<div style={{ height: `${virtualizer.getTotalSize()}px`, position: "relative" }}>
{virtualizer.getVirtualItems().map((virtualRow) => (
<div
key={virtualRow.index}
data-index={virtualRow.index}
ref={virtualizer.measureElement} // Measures actual height
style={{
position: "absolute",
top: 0,
left: 0,
width: "100%",
transform: `translateY(${virtualRow.start}px)`,
}}
>
<UserRow user={items[virtualRow.index]} />
</div>
))}
</div>
</div>
);
}
// Variable height items
const UserRow = React.memo(function UserRow({ user }) {
return (
<div className="user-row">
<img src={user.avatar} alt={user.name} />
<div>
<strong>{user.name}</strong>
<p>{user.email}</p>
</div>
</div>
);
});Web Vitals: What Actually Matters for Users
LCP (Largest Contentful Paint) ā Loading performance
Target: < 2.5s
Caused by: large images, slow server response, render-blocking JS
FID (First Input Delay) / INP (Interaction to Next Paint) ā Responsiveness
Target: < 100ms (FID), < 200ms (INP)
Caused by: long tasks blocking the main thread
CLS (Cumulative Layout Shift) ā Visual stability
Target: < 0.1
Caused by: images without dimensions, dynamically injected contentimport { onCLS, onFID, onLCP, onINP, onFCP, onTTFB } from "web-vitals";
function sendToAnalytics({ name, value, id, rating }) {
// rating is "good", "needs-improvement", or "poor"
analytics.track("web_vital", {
metric: name,
value: Math.round(name === "CLS" ? value * 1000 : value),
rating,
id,
page: window.location.pathname,
});
}
// Measure and report
onCLS(sendToAnalytics);
onFID(sendToAnalytics);
onLCP(sendToAnalytics);
onINP(sendToAnalytics);
// Fixing CLS: always specify image dimensions
// ā Layout shift as image loads
<img src={product.image} alt={product.name} />
// ā
Reserve space with aspect ratio
<div style={{ aspectRatio: "4/3" }}>
<img
src={product.image}
alt={product.name}
width={400}
height={300}
style={{ width: "100%", height: "auto" }}
/>
</div>React 18 Concurrent Features
useTransition ā Keep UI Responsive During Heavy Updates
function SearchPage() {
const [query, setQuery] = useState("");
const [results, setResults] = useState([]);
const [isPending, startTransition] = useTransition();
const handleSearch = (value) => {
setQuery(value); // Urgent ā updates input immediately
startTransition(() => {
// Non-urgent ā can be interrupted by more urgent updates
// React may pause this if the user types again
setResults(expensiveSearch(value, allProducts));
});
};
return (
<div>
<input
value={query}
onChange={(e) => handleSearch(e.target.value)}
placeholder="Search 10,000 products..."
/>
{isPending && <LinearProgress />} {/* Show while transition is pending */}
<SearchResults results={results} query={query} />
</div>
);
}useDeferredValue ā Debouncing Without setTimeout
function FilteredList({ filter }) {
// Defers updates to filter ā UI stays responsive
const deferredFilter = useDeferredValue(filter);
const isStale = deferredFilter !== filter;
const filtered = useMemo(
() => items.filter((item) => item.name.toLowerCase().includes(deferredFilter.toLowerCase())),
[deferredFilter]
);
return (
// Show stale state dimmed while deferred value catches up
<div style={{ opacity: isStale ? 0.5 : 1 }}>
{filtered.map((item) => (
<ListItem key={item.id} item={item} />
))}
</div>
);
}Image and Bundle Optimization
// Lazy load images below the fold
function ProductImage({ src, alt }) {
return (
<img
src={src}
alt={alt}
loading="lazy" // Native lazy loading
decoding="async" // Non-blocking decode
fetchPriority="low" // Hint to browser
/>
);
}
// Priority image (above the fold ā hero, LCP candidate)
function HeroImage({ src, alt }) {
return (
<img
src={src}
alt={alt}
fetchPriority="high" // Prioritize this load
loading="eager"
/>
);
}
// Code split aggressively by route
const routes = [
{ path: "/", component: lazy(() => import("./pages/Home")) },
{ path: "/products", component: lazy(() => import("./pages/Products")) },
{ path: "/checkout", component: lazy(() => import("./pages/Checkout")) },
// Admin panel: separate chunk, loaded only for admins
{ path: "/admin", component: lazy(() => import("./pages/Admin")) },
];Memoization Decision Tree
Is the component rendering unnecessarily?
ā Yes: Can I move state down or lift content up first?
ā Yes: Do that ā no memoization needed
ā No: Use React.memo
Are you passing a function as prop to a memoized child?
ā Yes: Use useCallback
Are you computing an expensive value?
ā Yes: Is it > 1ms? Does it run often?
ā Yes to both: Use useMemo
ā No: Skip it
Is the same computation in multiple components?
ā Yes: Use createSelector (Reselect) or move to a custom hook with useMemoInterview Questions Answered
Q: What is the difference between useMemo and React.memo?
React.memo memoizes a component ā wraps it so it skips re-rendering if props haven't changed. useMemo memoizes a computed value inside a component ā avoids recomputing an expensive derivation on every render. They're complementary: React.memo for components, useMemo for values inside components.
Q: What is reconciliation and how does React's diffing algorithm work?
Reconciliation is the process React uses to update the DOM efficiently. The diffing algorithm works by: (1) comparing trees level by level top-down, (2) assuming elements of different types produce different trees (full replace), (3) using key props to match list items across renders. This makes the algorithm O(n) rather than O(n³) for generic tree diffing.
Q: What is the purpose of the key prop during reconciliation?
Keys help React match elements in a list between renders. Without keys, React matches by position ā inserting an element at the start would shift all indexes and force full re-renders. With stable keys, React knows exactly which element is which, and only re-renders what actually changed.
Q: What is concurrent rendering in React 18?
Concurrent rendering lets React prepare multiple versions of the UI simultaneously. The render phase becomes interruptible ā React can pause an in-progress render to handle a higher-priority update (like typing in an input), then resume or discard the paused work. This enables useTransition, useDeferredValue, and Suspense for data fetching.
Enjoyed this article?
Explore the Frontend Engineering learning path for more.
Found this helpful?
Leave a comment
Have a question, correction, or just found this helpful? Leave a note below.