React Development · Lesson 9 of 15
Performance Optimization
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.