. Use react-dom profiling builds if available, or standard production builds for accurate metrics.
// next.config.js / webpack config
// Ensure you are testing the production build
module.exports = {
reactStrictMode: true, // Keep for dev, but profile prod
// ...
};
Install why-did-you-render for deep-dive analysis of re-render causes. This library patches React to log why a component re-rendered.
npm install @welldone-software/why-did-you-render --save-dev
// src/utils/why-did-you-render.ts
import React from 'react';
if (process.env.NODE_ENV === 'development') {
const whyDidYouRender = require('@welldone-software/why-did-you-render');
whyDidYouRender(React, {
trackAllPureComponents: true,
trackHooks: true,
logOnDifferentValues: true,
});
}
Step 2: Programmatic Profiling for Critical Paths
Use the React.Profiler component to measure specific interactions in production or staging. This allows you to log metrics to your analytics provider.
import React, { Profiler, ProfilerOnRenderCallback } from 'react';
interface PerformanceProfilerProps {
id: string;
children: React.ReactNode;
onLog?: (metrics: ProfilerMetrics) => void;
}
export interface ProfilerMetrics {
id: string;
phase: 'mount' | 'update';
actualDuration: number;
baseDuration: number;
startTime: number;
commitTime: number;
}
export const PerformanceProfiler: React.FC<PerformanceProfilerProps> = ({
id,
children,
onLog,
}) => {
const onRender: ProfilerOnRenderCallback = (
_id,
phase,
actualDuration,
baseDuration,
startTime,
commitTime
) => {
const metrics: ProfilerMetrics = {
id,
phase,
actualDuration,
baseDuration,
startTime,
commitTime,
};
// Threshold alerting
if (actualDuration > 16) {
console.warn(`[Perf] Slow render detected in ${id}: ${actualDuration}ms`);
}
onLog?.(metrics);
};
return (
<Profiler id={id} onRender={onRender}>
{children}
</Profiler>
);
};
Step 3: Analyze the Flamegraph
When using React DevTools Profiler:
- Record Interaction: Click the record button, perform the user action, and stop.
- Filter Wasted Renders: Toggle the "Show why components rendered" or filter by "Wasted" renders. These are components that re-rendered but produced the same output.
- Inspect Commits: Look at the "Committing" phase duration. If rendering is fast but committing is slow, the issue is likely excessive DOM updates or layout thrashing.
- Trace Props: Click on a component in the flamegraph to see which props changed. This identifies the source of the unnecessary update.
Step 4: Apply Targeted Optimizations
Based on the profile, apply fixes in order of impact:
A. State Colocation
If a global state update triggers renders in unrelated components, move state closer to where it is used.
// β Bad: Global state update triggers all consumers
const App = () => {
const { user, theme, notifications } = useGlobalStore();
return (
<div>
<Header user={user} theme={theme} />
<Sidebar notifications={notifications} />
<MainContent />
</div>
);
};
// β
Good: Colocate state or use selective subscriptions
const App = () => {
return (
<div>
<Header />
<Sidebar />
<MainContent />
</div>
);
};
// Header subscribes only to user/theme
const Header = () => {
const user = useGlobalStore((s) => s.user);
const theme = useGlobalStore((s) => s.theme);
return <header>{/* ... */}</header>;
};
B. Memoization for Expensive Sub-trees
Use React.memo only when the Profiler shows a component re-renders frequently with identical props and has non-trivial render cost.
import React from 'react';
interface ExpensiveListProps {
items: Item[];
onSelect: (id: string) => void;
}
// Profile shows this re-renders on every parent state change
// items and onSelect are stable references
export const ExpensiveList = React.memo<ExpensiveListProps>(
({ items, onSelect }) => {
// Heavy computation or large DOM tree
return (
<ul>
{items.map((item) => (
<li key={item.id} onClick={() => onSelect(item.id)}>
{item.name}
</li>
))}
</ul>
);
},
(prevProps, nextProps) => {
// Custom comparator for deep structures if necessary
return prevProps.items === nextProps.items;
}
);
C. Virtualization for Lists
If the Profiler indicates commit time spikes with list size, implement windowing.
import { FixedSizeList } from 'react-window';
const VirtualizedList = ({ items }: { items: Item[] }) => (
<FixedSizeList
height={600}
width="100%"
itemCount={items.length}
itemSize={50}
itemData={items}
>
{Row}
</FixedSizeList>
);
Pitfall Guide
1. The useMemo Trap
- Mistake: Wrapping every value in
useMemo.
- Reality:
useMemo has a cost. It creates a closure, stores state, and runs a comparison on every render. If the calculation is cheap (e.g., string concatenation, simple math), useMemo degrades performance.
- Best Practice: Only memoize expensive computations or objects/arrays passed to memoized children.
2. Profiling in Development Mode
- Mistake: Optimizing based on DevTools results in
development mode.
- Reality: Dev mode includes double-rendering, prop validation, and hooks debugging. A component that appears slow in dev may be perfectly fine in prod.
- Best Practice: Always verify performance fixes against a production build.
3. Ignoring "Render" vs. "Commit" Cost
- Mistake: Focusing solely on render duration.
- Reality: A component may render instantly but trigger a massive DOM update. The commit phase handles DOM mutations.
- Best Practice: Check the "Committing" bar in the Profiler. If commit time is high, reduce the number of DOM nodes or use
React.memo to prevent DOM updates.
4. Stable Props Fallacy
- Mistake: Assuming
React.memo works without stable props.
- Reality: If you pass inline objects or functions to a memoized component, it will re-render every time.
- Best Practice: Use
useCallback for functions and extract constant objects outside the component or use useMemo.
5. Over-Optimizing Low-Frequency Renders
- Mistake: Spending hours optimizing a modal that opens once per session.
- Reality: ROI is negative.
- Best Practice: Focus on high-frequency interactions (scrolling, typing, list filtering). The Profiler highlights these via render frequency counts.
6. State in Redux/Zustand Without Selectors
- Mistake: Subscribing to the entire store.
- Reality: Any store update triggers a re-render of all subscribers.
- Best Practice: Use selector functions to subscribe only to the specific slice of state needed.
7. CSS Layout Thrashing
- Mistake: React optimization doesn't fix layout shifts caused by CSS.
- Reality: Changing styles that affect layout (width, height, top) during React updates causes the browser to recalculate layout, adding latency.
- Best Practice: Use transforms and opacity for animations. Ensure dimensions are stable.
Production Bundle
Action Checklist
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|
| List > 100 items causing lag | Virtualization (react-window) | Reduces DOM nodes from N to viewport size. | High Perf Gain, Low Bundle |
| Expensive calculation on every render | useMemo | Caches result; skips computation on re-render. | Low Perf Gain, Low Bundle |
| Component re-renders with same props | React.memo | Skips render function and DOM diff. | Medium Perf Gain, Low Bundle |
| Global state update triggers unrelated UI | Selectors / State Colocation | Prevents subscribers from updating on irrelevant changes. | High Perf Gain, Zero Bundle |
| Inline function causes child re-render | useCallback | Stabilizes function reference. | Low Perf Gain, Low Bundle |
| Animation jank on scroll | CSS Transforms / useTransition | Offloads to compositor or defers non-urgent updates. | High Perf Gain, Zero Bundle |
Configuration Template
why-did-you-render Setup for Deep Diagnostics
Copy this into your application entry point to enable detailed re-render logging in development.
// src/entry.tsx
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
// Enable why-did-you-render in development
if (process.env.NODE_ENV === 'development') {
const whyDidYouRender = require('@welldone-software/why-did-you-render');
whyDidYouRender(React, {
// Track all pure components automatically
trackAllPureComponents: true,
// Track hooks to see why hooks caused updates
trackHooks: true,
// Log when props differ by value but not reference
logOnDifferentValues: true,
// Exclude components that are known to be noisy
// exclude: [/^ErrorBoundary$/, /^ReactDevTools/],
});
}
const root = ReactDOM.createRoot(document.getElementById('root')!);
root.render(
<React.StrictMode>
<App />
</React.StrictMode>
);
Custom Profiler Hook for Analytics
Use this hook to integrate profiling data with your monitoring service.
// src/hooks/useProfilerMetrics.ts
import { useCallback } from 'react';
import type { ProfilerOnRenderCallback } from 'react';
export const useProfilerMetrics = (): ProfilerOnRenderCallback => {
return useCallback(
(
id: string,
phase: 'mount' | 'update',
actualDuration: number,
baseDuration: number,
startTime: number,
commitTime: number
) => {
// Send to analytics only if duration exceeds threshold
if (actualDuration > 16 || commitTime > 16) {
console.debug(`[Perf] ${id} | ${phase} | Actual: ${actualDuration.toFixed(2)}ms | Commit: ${commitTime.toFixed(2)}ms`);
// Example: Send to Datadog/NewRelic
// window.dd && window.dd.logger.debug('react-perf', { id, phase, actualDuration, commitTime });
}
},
[]
);
};
Quick Start Guide
- Open DevTools: Launch Chrome DevTools and navigate to the Profiler tab.
- Enable Profiler: Click the gear icon and ensure "Record why each component rendered while profiling" is checked.
- Start Recording: Click the record button (β) and perform the user interaction you want to optimize.
- Stop & Analyze: Click stop. Review the flamegraph. Click the "Committing" phase to see DOM costs. Toggle "Show why components rendered" to identify prop changes.
- Iterate: Apply one fix, re-record, and compare the flamegraph. Focus on reducing the height of the bars and the number of red "Wasted" renders. Repeat until interaction latency is under 16ms.