React Rendering - Optimization
Question 1: How to prevent unnecessary re-renders in React? (React.memo, useMemo, useCallback)β
Answerβ
Preventing unnecessary re-renders is crucial for React application performance. React provides three main optimization tools: React.memo (for component memoization), useMemo (for expensive computation memoization), and useCallback (for function reference stability). These work together to minimize wasteful render cycles.
React.memo wraps functional components and performs shallow comparison of props. If props haven't changed, React skips rendering the component and reuses the last rendered result. This is equivalent to PureComponent for class components.
useMemo memoizes the result of expensive calculations, only recomputing when dependencies change. This prevents recalculating derived data on every render.
useCallback memoizes function references, ensuring callbacks maintain the same reference across renders unless dependencies change. This is essential when passing callbacks to memoized child components, as new function references would break memoization.
The key principle is referential equality: JavaScript compares objects and functions by reference, not value. React's reconciliation relies on this for efficient diffing. When parent components re-render, they create new object/function references, triggering child re-renders even if the actual values are identical. These optimization tools preserve references, preventing unnecessary cascading re-renders.
However, these are optimizations, not defaults. React is already fast, and premature optimization can hurt performance more than help. Only optimize when you identify actual performance bottlenecks through profiling.
π Deep Diveβ
React.memo Implementation Details and Internal Architecture
React.memo is a higher-order component that implements shallow comparison by default, providing a critical optimization layer in React's reconciliation process. When a component wrapped with React.memo is about to render, React compares the new props with the previous props using Object.is() comparison for each prop. This comparison happens before the component enters the render phase, making it an early-bailout optimization that saves significant work.
The internal implementation leverages React Fiber's architecture. When React processes a fiber node for a memoized component, it first checks if this is an update (not initial mount). If it's an update, React retrieves the previous props from the fiber's memoizedProps field and performs the comparison. This comparison is O(n) where n is the number of props, but typically very fast since most components have fewer than 10 props.
// React.memo internal logic (simplified from React source)
function memo(Component, compare) {
return function MemoizedComponent(props) {
const prevPropsRef = useRef();
// Custom comparison or default shallow comparison
const arePropsEqual = compare
? compare(prevPropsRef.current, props)
: shallowEqual(prevPropsRef.current, props);
if (arePropsEqual) {
return cachedResult; // Skip rendering - reuse previous fiber
}
prevPropsRef.current = props;
const result = Component(props);
cachedResult = result;
return result;
};
}
function shallowEqual(objA, objB) {
// Fast path: Same reference
if (Object.is(objA, objB)) return true;
// Type check
if (typeof objA !== 'object' || objA === null ||
typeof objB !== 'object' || objB === null) {
return false;
}
const keysA = Object.keys(objA);
const keysB = Object.keys(objB);
// Different number of props = definitely different
if (keysA.length !== keysB.length) return false;
// Compare each prop value using Object.is (handles NaN, -0/+0)
for (let i = 0; i < keysA.length; i++) {
const key = keysA[i];
if (!Object.hasOwnProperty.call(objB, key) ||
!Object.is(objA[key], objB[key])) {
return false;
}
}
return true;
}
Why Object.is() instead of ===? Object.is() handles edge cases correctly: Object.is(NaN, NaN) returns true (unlike ===), and Object.is(+0, -0) returns false (correctly distinguishes signed zeros). This matters for mathematical computations and ensures more predictable behavior.
Custom Comparison Functions: Advanced Use Cases
For complex props, you can provide a custom comparison function. This is powerful but must be used carefully to avoid subtle bugs:
const UserProfile = React.memo(
({ user, settings, onUpdate }) => {
return (
<div>
<h1>{user.name}</h1>
<p>{user.email}</p>
<Settings config={settings} />
<button onClick={() => onUpdate(user.id)}>Update</button>
</div>
);
},
(prevProps, nextProps) => {
// Custom comparison: only re-render if user.id or user.email changes
// Ignore settings and onUpdate changes (risky! see below)
return prevProps.user.id === nextProps.user.id &&
prevProps.user.email === nextProps.user.email;
// β οΈ WARNING: This ignores settings and onUpdate changes!
// If onUpdate logic changes, this component won't re-render
// Use this pattern only when you're CERTAIN those props don't affect output
}
);
// β
Better: Include all props that affect output
const UserProfileBetter = React.memo(
UserProfile,
(prevProps, nextProps) => {
// Return true if props are EQUAL (component should NOT re-render)
const userUnchanged = prevProps.user.id === nextProps.user.id &&
prevProps.user.name === nextProps.user.name &&
prevProps.user.email === nextProps.user.email;
const settingsUnchanged = JSON.stringify(prevProps.settings) ===
JSON.stringify(nextProps.settings);
// Note: Function props usually change, so we skip onUpdate comparison
// But we accept re-renders when user or settings change
return userUnchanged && settingsUnchanged;
}
);
useMemo Deep Dive: Dependency Comparison and Caching Strategy
useMemo prevents expensive recalculations by caching results. React stores both the memoized value and the dependency array in the fiber's memoizedState. On each render, it compares the current dependencies with the previous ones using Object.is() for each dependency.
The internal implementation uses a linked list of hooks attached to the fiber node. Each useMemo call gets its own hook entry containing:
- The memoized value
- The previous dependency array
- A pointer to the next hook
// useMemo internal logic (simplified from React hooks implementation)
function useMemo(factory, deps) {
const hook = getCurrentHook(); // Retrieves hook from fiber's memoizedState linked list
const prevDeps = hook.deps;
if (prevDeps !== null) {
// Compare each dependency using Object.is()
if (areHookInputsEqual(deps, prevDeps)) {
return hook.memoizedValue; // Return cached value - skip factory execution
}
}
// Dependencies changed or first render - execute factory
const value = factory();
hook.memoizedValue = value;
hook.deps = deps;
return value;
}
function areHookInputsEqual(nextDeps, prevDeps) {
if (prevDeps === null) return false; // First render
// Compare each dependency
for (let i = 0; i < prevDeps.length; i++) {
if (Object.is(nextDeps[i], prevDeps[i])) {
continue; // This dependency unchanged
}
return false; // Found a changed dependency
}
return true; // All dependencies unchanged
}
Critical Performance Insight: useMemo adds overhead (dependency comparison + array storage). It only improves performance when the factory function is significantly more expensive than the comparison. As a rule of thumb, use useMemo when the factory takes >2-3ms to execute.
useCallback Mechanics and the Function Identity Problem
useCallback is essentially useMemo for functions. In fact, useCallback(fn, deps) is literally implemented as useMemo(() => fn, deps) in React's source code:
// useCallback internal logic (from React source)
function useCallback(callback, deps) {
return useMemo(() => callback, deps);
}
The critical insight: without useCallback, every render creates a new function reference, breaking memoization in child components. JavaScript functions are compared by reference, not by their code:
const fn1 = () => console.log('hello');
const fn2 = () => console.log('hello');
console.log(fn1 === fn2); // false - different references!
This means React's shallow comparison sees them as different props, triggering re-renders.
Advanced Dependency Array Patterns
The dependency array is critical but often misunderstood. Common mistakes and solutions:
// β MISTAKE 1: Missing dependencies - creates stale closures
const handleSubmit = useCallback(() => {
console.log(formData); // BUG: Always logs initial formData value!
}, []); // Should include formData
// β MISTAKE 2: Too many dependencies - defeats purpose
const handleClick = useCallback(() => {
doSomething();
}, [formData, userId, settings, theme, locale, permissions]);
// Re-creates on every formData/settings change - might as well not use useCallback
// β MISTAKE 3: Object/array dependencies causing infinite loops
const handleUpdate = useCallback(() => {
updateUser(config); // config is recreated every render
}, [config]); // This callback recreates every render!
// β
SOLUTION 1: Include all dependencies (accept re-creation)
const handleSubmit = useCallback(() => {
console.log(formData);
}, [formData]); // Recreates when formData changes - correct!
// β
SOLUTION 2: Use refs for values that shouldn't trigger re-creation
const latestFormData = useRef(formData);
useEffect(() => {
latestFormData.current = formData; // Keep ref updated
}, [formData]);
const handleSubmit = useCallback(() => {
console.log(latestFormData.current); // Always latest value, stable reference
}, []); // Never recreates
// β
SOLUTION 3: Memoize object/array dependencies
const config = useMemo(() => ({
theme: 'dark',
locale: 'en'
}), []); // Stable reference
const handleUpdate = useCallback(() => {
updateUser(config);
}, [config]); // Only recreates if config memoization dependencies change
// β
SOLUTION 4: Use functional updates to avoid dependencies
const handleIncrement = useCallback(() => {
setCount(prevCount => prevCount + 1); // No dependency on count!
}, []); // Stable reference, no stale closure
React Fiber and Bailout Mechanisms: The Full Picture
React Fiber (React 16+) includes sophisticated bailout mechanisms that work in conjunction with memoization. Understanding these helps you optimize effectively.
React's reconciliation has multiple bailout opportunities:
- Early bailout (React.memo): Before entering render phase, check if props changed
- Render phase bailout: During render, check if state/context changed
- Commit phase bailout: Skip DOM updates if output identical
Fiber Bailout Conditions:
- Props bailout: New props === old props (referential equality) OR shallow equal (with React.memo)
- State bailout:
Object.is(newState, oldState)returns true - Context bailout: Context value unchanged (referential equality)
When React bails out:
- Component's render function NOT called
- Children NOT reconciled (entire subtree skipped)
- Side effects (useEffect) NOT triggered
- Massive performance win for deep component trees
React.memo enhances the props bailout by adding a custom comparison before React Fiber even schedules the work. This is why React.memo is so powerful - it prevents work from entering the reconciliation process entirely, saving CPU cycles at the earliest possible stage.
π Real-World Scenarioβ
Production Crisis: Dashboard Performance Collapse
Context: E-commerce analytics dashboard with real-time metrics. After adding new features, users reported freezing UI, unresponsive controls, and 5-10 second delays when interacting with filters.
Initial Symptoms:
- Dropdown interactions: 3-5 second lag
- Chart hover tooltips: stuttering, delayed appearance
- Filter changes: complete UI freeze for 2-4 seconds
- CPU usage: spiking to 100% during interactions
- Frame rate: dropping from 60fps to 5-10fps
The Investigation
Step 1: React DevTools Profiler Baseline
Opened React DevTools Profiler, recorded a filter change interaction:
Recording results:
- Total render time: 2,847ms
- Components rendered: 247
- Committed: 1
- Ranked chart showing:
* Dashboard (root): 2,847ms
* MetricsGrid: 2,156ms
* ChartContainer: 1,834ms (rendered 6 times)
* DataTable: 892ms (rendered 3 times)
* FilterBar: 123ms
Step 2: Flamegraph Analysis
The flamegraph revealed cascading re-renders:
- Changing one filter triggered entire Dashboard re-render
- Dashboard re-render caused all 6 charts to re-render
- Each chart recalculated expensive data transformations
- DataTable with 1000+ rows re-rendered completely
Step 3: Component Analysis
Examined the problematic component structure:
// β BEFORE: Unoptimized Dashboard
function Dashboard() {
const [filters, setFilters] = useState({
dateRange: 'week',
category: 'all',
region: 'global'
});
const [realtimeData, setRealtimeData] = useState([]);
// WebSocket updates every 2 seconds
useEffect(() => {
const ws = new WebSocket('wss://api.example.com/metrics');
ws.onmessage = (event) => {
setRealtimeData(JSON.parse(event.data)); // Triggers full re-render
};
}, []);
// Recalculated on EVERY render (including realtime updates)
const salesData = realtimeData
.filter(d => matchesFilters(d, filters))
.map(d => ({
...d,
revenue: calculateRevenue(d), // Expensive
margin: calculateMargin(d) // Expensive
}))
.sort((a, b) => b.revenue - a.revenue);
const chartData = transformForChart(salesData); // More expensive computation
// New function reference every render
const handleFilterChange = (key, value) => {
setFilters({ ...filters, [key]: value });
};
return (
<div>
<FilterBar
filters={filters}
onChange={handleFilterChange} // New reference = re-render
/>
{/* All 6 charts re-render on any change */}
<MetricsGrid>
<RevenueChart data={chartData.revenue} />
<OrdersChart data={chartData.orders} />
<CustomersChart data={chartData.customers} />
<ConversionChart data={chartData.conversion} />
<GeographyChart data={chartData.geography} />
<TrendsChart data={chartData.trends} />
</MetricsGrid>
<DataTable
data={salesData} // New array reference = re-render
pageSize={50}
/>
</div>
);
}
Performance Metrics Before Optimization:
- Filter change to paint: 2,847ms
- Realtime update impact: 892ms (every 2 seconds)
- Total component renders per filter change: 247
- JavaScript execution time: 2,134ms
- Frame rate during filter change: 8fps
- Main thread blocked: 2,400ms
The Root Causes
- Realtime updates triggering full re-renders: Every 2-second WebSocket update caused complete Dashboard re-render, including all expensive calculations
- No memoization of expensive computations: Data transformations recalculated 30+ times per second during interactions
- Unstable function references:
handleFilterChangerecreated every render, breaking child memoization - No component memoization: Charts and tables re-rendered even when their specific data hadn't changed
- Object/array recreation: New references for
chartDataandsalesDataevery render
The Solution
// β
AFTER: Optimized Dashboard
function Dashboard() {
const [filters, setFilters] = useState({
dateRange: 'week',
category: 'all',
region: 'global'
});
const [realtimeData, setRealtimeData] = useState([]);
// WebSocket still updates every 2 seconds
useEffect(() => {
const ws = new WebSocket('wss://api.example.com/metrics');
ws.onmessage = (event) => {
setRealtimeData(JSON.parse(event.data));
};
}, []);
// Stable callback reference
const handleFilterChange = useCallback((key, value) => {
setFilters(prev => ({ ...prev, [key]: value }));
}, []);
// Only recalculate when filters or data change
const salesData = useMemo(() => {
console.log('Recalculating salesData');
return realtimeData
.filter(d => matchesFilters(d, filters))
.map(d => ({
...d,
revenue: calculateRevenue(d),
margin: calculateMargin(d)
}))
.sort((a, b) => b.revenue - a.revenue);
}, [realtimeData, filters]);
// Only recalculate when salesData changes
const chartData = useMemo(() => {
console.log('Recalculating chartData');
return transformForChart(salesData);
}, [salesData]);
// Individual chart data memoization
const revenueData = useMemo(() => chartData.revenue, [chartData]);
const ordersData = useMemo(() => chartData.orders, [chartData]);
const customersData = useMemo(() => chartData.customers, [chartData]);
const conversionData = useMemo(() => chartData.conversion, [chartData]);
const geographyData = useMemo(() => chartData.geography, [chartData]);
const trendsData = useMemo(() => chartData.trends, [chartData]);
return (
<div>
<FilterBarMemo
filters={filters}
onChange={handleFilterChange}
/>
<MetricsGrid>
<RevenueChartMemo data={revenueData} />
<OrdersChartMemo data={ordersData} />
<CustomersChartMemo data={customersData} />
<ConversionChartMemo data={conversionData} />
<GeographyChartMemo data={geographyData} />
<TrendsChartMemo data={trendsData} />
</MetricsGrid>
<DataTableMemo
data={salesData}
pageSize={50}
/>
</div>
);
}
// Memoize all child components
const FilterBarMemo = React.memo(FilterBar);
const RevenueChartMemo = React.memo(RevenueChart);
const OrdersChartMemo = React.memo(OrdersChart);
const CustomersChartMemo = React.memo(CustomersChart);
const ConversionChartMemo = React.memo(ConversionChart);
const GeographyChartMemo = React.memo(GeographyChart);
const TrendsChartMemo = React.memo(TrendsChart);
// DataTable with custom comparison for large datasets
const DataTableMemo = React.memo(
DataTable,
(prevProps, nextProps) => {
// Only re-render if data length changed or first item changed
// (Assumes sorted data, first item change = data changed)
return prevProps.data.length === nextProps.data.length &&
prevProps.data[0]?.id === nextProps.data[0]?.id &&
prevProps.pageSize === nextProps.pageSize;
}
);
Performance Metrics After Optimization:
- Filter change to paint: 187ms (93% improvement)
- Realtime update impact: 12ms (98% improvement)
- Total component renders per filter change: 8 (96% reduction)
- JavaScript execution time: 89ms (95% improvement)
- Frame rate during filter change: 58fps (625% improvement)
- Main thread blocked: 120ms (95% improvement)
Impact Verification
React DevTools Profiler after optimization:
Recording results (filter change):
- Total render time: 187ms
- Components rendered: 8
- Committed: 1
- Ranked chart showing:
* Dashboard (root): 187ms
* FilterBar: 45ms (only component that needed to re-render)
* salesData calculation: 67ms
* chartData calculation: 43ms
* Charts: 0ms (skipped - memoized)
* DataTable: 0ms (skipped - memoized)
Key Lessons:
- Always profile before optimizing - the realtime updates were invisible without DevTools
- Memoize expensive calculations with
useMemo- transformed 2s freeze into 67ms calculation - Stabilize callbacks with
useCallback- prevented cascading re-renders - Memoize components strategically with
React.memo- most charts didn't need re-rendering - Custom comparison functions for complex props - DataTable optimization saved 800ms
- Monitor production performance - this issue only appeared with production data volumes
<details> <summary><strong>βοΈ Trade-offs: When to Use React.memo</strong></summary>
When to Use React.memo
β Good Candidates:
- Expensive render components: Charts, data visualizations, complex tables
- Pure presentational components: Components that always render the same output for same props
- List items in large lists: Prevent re-rendering all items when one changes
- Components that receive stable props: Props that rarely change
- Components deep in the tree: Prevent cascading re-renders
β Bad Candidates:
- Components that always receive new props: Props change on every parent render
- Cheap components: Render time < 2ms (memo overhead may be worse)
- Components with children prop:
childrenis always a new reference - Components near the top of tree: Small tree beneath = little benefit
Performance Cost Analysis:
// Scenario 1: Memo helps (expensive component, stable props)
const ExpensiveChart = React.memo(({ data }) => {
// Render cost: 50ms
// Memo comparison cost: 0.1ms
// Net benefit: 49.9ms saved when props unchanged
});
// Scenario 2: Memo hurts (cheap component, changing props)
const SimpleButton = React.memo(({ onClick, label }) => {
// Render cost: 0.5ms
// Memo comparison cost: 0.1ms
// Net loss: 0.1ms added when props always change
return <button onClick={onClick}>{label}</button>;
});
When to Use useMemo
β Good Use Cases:
- Computationally expensive calculations: Loops over large arrays, complex algorithms
- Object/array stabilization for memo: Prevent child re-renders
- Derived state from expensive operations: Filtering, sorting, transforming large datasets
- Referential equality matters: Passing to dependencies of other hooks
β Don't Use For:
- Simple calculations:
a + b, string concatenation - Object literals with primitive values: Unless passed to memoized children
- Every variable: Creates more overhead than benefit
Benchmark Guideline:
// β Premature optimization
const fullName = useMemo(
() => `${firstName} ${lastName}`,
[firstName, lastName]
); // Overhead > benefit
// β
Justified optimization
const sortedAndFiltered = useMemo(() => {
return items
.filter(item => item.price > minPrice)
.sort((a, b) => b.rating - a.rating)
.slice(0, 100);
}, [items, minPrice]); // Expensive operation
When to Use useCallback
β Essential For:
- Props passed to memoized components: Prevents breaking memoization
- Dependencies of other hooks:
useEffect,useMemodependencies - Context providers: Prevent context consumers from re-rendering
- Debounced/throttled functions: Maintain consistent reference
β Unnecessary For:
- Event handlers on DOM elements: DOM doesn't care about reference equality
- Callbacks not passed to children: No benefit if not used in dependencies
- Every function: Adds memory overhead
Decision Matrix:
| Scenario | React.memo | useMemo | useCallback |
|---|---|---|---|
| Expensive render (>10ms) | β Yes | - | - |
| Expensive calculation | - | β Yes | - |
| Callback to memoized child | - | - | β Yes |
| Props rarely change | β Yes | Maybe | Maybe |
| Large list items | β Yes | β For data | β For handlers |
| Simple component (<2ms) | β No | - | - |
| Props always change | β No | β No | β No |
| DOM event handlers | - | - | β No |
Premature Optimization Trap
The most common mistake is optimizing too early:
// β Cargo-cult optimization (wrapping everything)
const App = React.memo(() => {
const [count, setCount] = useState(0);
const increment = useCallback(() => {
setCount(c => c + 1);
}, []);
const doubled = useMemo(() => count * 2, [count]);
const config = useMemo(() => ({ theme: 'dark' }), []);
return (
<div>
<CounterMemo value={doubled} onIncrement={increment} />
<ThemeMemo config={config} />
</div>
);
});
const CounterMemo = React.memo(Counter);
const ThemeMemo = React.memo(Theme);
// Problems:
// 1. App doesn't need memo (top-level)
// 2. doubled is cheap calculation
// 3. config memoization only helps if Theme is heavy
// 4. More code complexity for minimal gain
Correct Approach:
- Build first, optimize later: Write clean code, then profile
- Measure before optimizing: Use React DevTools Profiler
- Optimize bottlenecks only: Focus on components taking >10ms
- Keep code simple: Optimization should be invisible to maintainers
Memory vs Performance Trade-off
Memoization stores values in memory:
// Memory cost example
const DataGrid = ({ items }) => {
// Stores filtered array in memory
const filtered = useMemo(
() => items.filter(i => i.active),
[items]
);
// Stores sorted array in memory
const sorted = useMemo(
() => [...filtered].sort((a, b) => a.name.localeCompare(b.name)),
[filtered]
);
// Stores grouped object in memory
const grouped = useMemo(
() => groupBy(sorted, 'category'),
[sorted]
);
// Total memory: 3 copies of data in different forms
// Trade-off: 3x memory for better render performance
};
For large datasets: Consider virtualization (react-window) instead of memoization.
Developer Experience Trade-off
Optimization adds complexity:
// Before: Simple and readable
function TodoList({ todos, onToggle }) {
return todos.map(todo => (
<TodoItem
key={todo.id}
todo={todo}
onToggle={onToggle}
/>
));
}
// After: Optimized but more complex
const TodoList = React.memo(({ todos, onToggle }) => {
return todos.map(todo => (
<TodoItemMemo
key={todo.id}
todo={todo}
onToggle={onToggle}
/>
));
});
const TodoItemMemo = React.memo(
TodoItem,
(prev, next) => {
return prev.todo.id === next.todo.id &&
prev.todo.completed === next.todo.completed &&
prev.todo.text === next.todo.text;
}
);
// Questions developers will ask:
// - Why is this memoized?
// - What performance problem does this solve?
// - Can we remove this?
Best Practice: Document why optimization exists:
// Memoized because this list can have 1000+ items
// and each item renders a complex chart (50ms each)
// Without memo: 50s to render list after filter change
// With memo: 200ms (only changed items re-render)
const TodoItemMemo = React.memo(TodoItem);
π¬ Explain to Juniorβ
The Restaurant Kitchen Analogy
Imagine a restaurant kitchen (React app) preparing meals (rendering components).
Without Optimization (Everything from scratch):
- Customer changes their drink order
- Kitchen throws out the entire meal and starts over
- Re-cooks the appetizer, main course, and dessert
- Re-makes all drinks including unchanged ones
- Extreme waste of time and resources
With React.memo (Smart caching):
- Chef remembers what each customer ordered
- When drink changes, only re-makes the drink
- Appetizer, main, dessert stay the same (cached)
- Kitchen only works on what actually changed
With useMemo (Prep work):
- Kitchen pre-chops vegetables once
- Stores them in containers (memoized)
- Reuses chopped veggies for multiple dishes
- Only re-chops when vegetables run out (dependency changed)
With useCallback (Consistent communication):
- Head chef gives instructions to sous chefs
- Uses the same instruction card (stable reference)
- Sous chefs recognize the card and don't ask questions
- If card changes, they know something's different
Simple Code Example for Beginners
// Problem: Why does this log "Child rendered" when clicking the parent button?
function Parent() {
const [parentCount, setParentCount] = useState(0);
return (
<div>
<button onClick={() => setParentCount(parentCount + 1)}>
Parent Count: {parentCount}
</button>
<Child /> {/* Why does this re-render? */}
</div>
);
}
function Child() {
console.log('Child rendered');
return <div>I'm a child</div>;
}
// Answer: React re-renders children by default when parent re-renders
// Solution: Tell React to skip Child if nothing changed
const Child = React.memo(function Child() {
console.log('Child rendered'); // Now only logs once
return <div>I'm a child</div>;
});
Step-by-Step: When to Add Optimization
Step 1: Write code normally (no optimization)
function App() {
const [search, setSearch] = useState('');
const [items] = useState(generateLargeList()); // 10,000 items
const filtered = items.filter(item =>
item.name.toLowerCase().includes(search.toLowerCase())
);
return (
<div>
<input
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
<List items={filtered} />
</div>
);
}
Step 2: Notice it's slow (typing in search is laggy)
Step 3: Add useMemo
// Only filter when search or items change
const filtered = useMemo(() =>
items.filter(item =>
item.name.toLowerCase().includes(search.toLowerCase())
),
[search, items]
);
Step 4: Still slow? Memoize the List component
const List = React.memo(({ items }) => {
return items.map(item => <ListItem key={item.id} item={item} />);
});
const ListItem = React.memo(({ item }) => {
return <div>{item.name}</div>;
});
Interview Answer Template
Question: "How do you prevent unnecessary re-renders in React?"
Template Answer:
"React provides three main tools for preventing unnecessary re-renders: React.memo, useMemo, and useCallback.
React.memo is a higher-order component that memoizes the result of a component render. It performs a shallow comparison of props and skips rendering if props haven't changed. I use this for expensive components or list items that shouldn't re-render when parent state changes.
useMemo memoizes the result of expensive calculations. It only recomputes when dependencies change. I use this for filtering large arrays, complex transformations, or any calculation that takes more than a few milliseconds.
useCallback memoizes function references, which is crucial when passing callbacks to memoized components. Without it, you create a new function reference on every render, which breaks the child's memoization.
However, I'm careful not to optimize prematurely. I first build the feature, then use React DevTools Profiler to identify actual bottlenecks. Only then do I apply these optimizations where they provide measurable benefit.
For example, in a recent project, we had a dashboard with real-time updates causing severe lag. Profiling revealed that 6 charts were re-rendering unnecessarily on every WebSocket message. By wrapping the charts with React.memo and memoizing the data transformations with useMemo, we reduced render time from 2.8 seconds to 187ms - a 93% improvement."
Common Mistakes to Avoid:
// β Mistake 1: Wrapping everything in memo
const Button = React.memo(({ onClick, children }) => (
<button onClick={onClick}>{children}</button>
));
// This is overkill - buttons are cheap to render
// β Mistake 2: Memoizing simple calculations
const total = useMemo(() => price + tax, [price, tax]);
// Addition is faster than memoization overhead
// β Mistake 3: Missing dependencies
const filtered = useMemo(() =>
items.filter(i => i.category === selectedCategory),
[items] // Missing selectedCategory!
);
// β Mistake 4: Using memo with children prop
const Container = React.memo(({ children }) => (
<div className="container">{children}</div>
));
// children is always a new reference - memo is useless
// β
Correct: Only optimize when needed
const ExpensiveChart = React.memo(Chart); // Chart takes 50ms to render
const data = useMemo(() => transform(rawData), [rawData]); // transform is expensive
const handleClick = useCallback(() => {...}, []); // Passed to memoized child
Quick Reference Rules:
- React.memo: Use for components that are expensive to render (>10ms) and receive stable props
- useMemo: Use for expensive calculations (array operations on large datasets, complex algorithms)
- useCallback: Use when passing callbacks to memoized components or using in hook dependencies
- Always: Profile first with React DevTools before optimizing
- Remember: Optimization adds complexity - only do it when there's a measurable performance problem
Question 2: How to profile and optimize React app performance?β
Answerβ
Profiling React applications requires a systematic approach using specialized tools to identify bottlenecks and measure optimization impact. The primary tools are React DevTools Profiler, Chrome DevTools Performance Tab, and third-party libraries like Why Did You Render.
React DevTools Profiler is specifically designed for React applications. It records component render times, identifies unnecessary re-renders, and shows which components committed to the DOM. The Profiler provides flamegraphs (hierarchical visualization), ranked charts (components sorted by render time), and interaction tracking.
Chrome DevTools Performance Tab provides lower-level insights into JavaScript execution, layout, paint, and composite operations. It shows the browser's rendering pipeline, identifies long tasks blocking the main thread, and measures Time to Interactive (TTI) and First Contentful Paint (FCP).
Why Did You Render is a development library that logs why components re-rendered, showing which props or state changed. It's invaluable for debugging unexpected re-renders.
The optimization workflow follows these steps: (1) Establish baseline metrics - record current performance, (2) Profile user interactions - identify slow operations, (3) Analyze flamegraphs and timelines - find bottlenecks, (4) Apply targeted optimizations - memo, code splitting, lazy loading, (5) Measure impact - verify improvements, (6) Set performance budgets - prevent regressions.
Key metrics to monitor include Component Render Time (time spent in render), Commit Time (time spent updating DOM), Interactions (user input to visual response), Frame Rate (should be 60fps), and JavaScript Bundle Size (affects load time).
π Deep Diveβ
React DevTools Profiler Internals and Architecture
The Profiler works by instrumenting React's reconciliation process at the fiber level. When you start recording, React begins tracking timing information for each component's render phase and commit phase. This instrumentation adds minimal overhead (<5%) in development builds, making it safe to profile during development without significantly skewing results.
Internally, React's Profiler uses the browser's performance.now() API for high-precision timing. When a fiber begins rendering, React marks the start time. When the fiber completes, React calculates the duration and stores it in the fiber's metadata. This data is then aggregated and sent to DevTools for visualization.
The Profiler distinguishes between two critical phases:
- Render phase (interruptible): React calls component functions, diffs the virtual DOM, decides what changed
- Commit phase (synchronous): React applies changes to the real DOM, runs effects
Understanding this separation helps you identify whether bottlenecks are in component logic (render phase) or DOM manipulation (commit phase).
Profiler API Integration for Production Monitoring:
// Profiler component API (for programmatic profiling)
import { Profiler } from 'react';
function onRenderCallback(
id, // "id" prop of the Profiler tree (e.g., "Dashboard")
phase, // "mount" (first render) or "update" (subsequent render)
actualDuration, // Time spent rendering THIS update (with memoization)
baseDuration, // Estimated time to render entire subtree WITHOUT memoization
startTime, // When React began rendering this update (timestamp)
commitTime, // When React committed this update to DOM (timestamp)
interactions // Set of interactions that triggered this update (React 18+)
) {
// Send to analytics for production monitoring
console.log(`${id} (${phase}) took ${actualDuration}ms`);
// baseDuration vs actualDuration shows memoization effectiveness
const memoizationBenefit = baseDuration - actualDuration;
console.log(`Memoization saved ${memoizationBenefit}ms (${Math.round(memoizationBenefit / baseDuration * 100)}%)`);
// Real User Monitoring (RUM) - track slow renders
if (actualDuration > 100) {
analytics.track('slow-component-render', {
componentId: id,
phase,
duration: actualDuration,
baselineDuration: baseDuration,
memoizationEffectiveness: `${Math.round((1 - actualDuration / baseDuration) * 100)}%`,
timestamp: commitTime,
url: window.location.pathname,
userAgent: navigator.userAgent
});
}
}
function App() {
return (
<Profiler id="App" onRender={onRenderCallback}>
<Dashboard />
</Profiler>
);
}
// Nest Profilers for granular tracking
function Dashboard() {
return (
<div>
<Profiler id="Dashboard-Charts" onRender={onRenderCallback}>
<ChartsSection />
</Profiler>
<Profiler id="Dashboard-Table" onRender={onRenderCallback}>
<DataTable />
</Profiler>
</div>
);
}
Understanding Profiler Metrics in Depth:
- actualDuration: Most important metric - actual time spent rendering. If this is high (>50ms), you have a performance problem.
- baseDuration: Theoretical maximum if nothing was memoized. Useful for calculating memoization ROI.
- startTime & commitTime: Use difference to calculate render phase vs commit phase time. Large gaps indicate expensive DOM updates.
- phase: "mount" renders are always slower (creating DOM nodes). Focus optimization on "update" phase.
- interactions: Track user actions that triggered renders. Helps correlate performance with user behavior.
Flamegraph Deep Interpretation:
Dashboard (2,847ms total)
βββ FilterBar (123ms)
βββ MetricsGrid (2,156ms) β 76% of parent time
β βββ RevenueChart (412ms) β 19% of MetricsGrid
β βββ OrdersChart (389ms)
β βββ CustomersChart (367ms)
β βββ ConversionChart (344ms)
β βββ GeographyChart (322ms)
β βββ TrendsChart (322ms)
βββ DataTable (892ms)
βββ TableHeader (45ms) β Only 5% of DataTable
βββ TableBody (847ms) β 95% of DataTable (bottleneck!)
βββ TableRow (847ms / 1000 items = 0.847ms each)
βββ TableCell (0.8ms each Γ 5 columns)
Reading the flamegraph strategically:
- Width: Proportional to render time (wider = slower). Focus on widest bars first.
- Color intensity: Gray = didn't render (skipped), Yellow = fast (<16ms), Orange = slow (16-50ms), Dark Orange/Red = very slow (>50ms)
- Hierarchy: Parent time INCLUDES all children. To find actual component work, hover for "self time".
- Self time: Time spent in component's own code (excluding children). High self time = optimization target.
Chrome DevTools Performance: The Complete Picture
React DevTools shows what is slow (which components); Chrome DevTools shows why (JavaScript, layout, paint).
Recording a Performance Profile with React Integration:
// User Timing API (React automatically uses this)
// React marks significant events:
performance.mark('βοΈ App render start');
// ... render phase ...
performance.mark('βοΈ App render stop');
performance.measure('βοΈ App render', 'βοΈ App render start', 'βοΈ App render stop');
// You can add custom marks for your expensive operations:
function expensiveDataTransform(data) {
performance.mark('data-transform-start');
const result = data
.filter(item => item.active)
.map(item => ({ ...item, computed: heavyCalculation(item) }))
.sort((a, b) => b.priority - a.priority);
performance.mark('data-transform-end');
performance.measure('data-transform', 'data-transform-start', 'data-transform-end');
return result;
}
// View in Chrome DevTools: Performance β User Timing section
Performance Timeline Layers (Complete Breakdown):
-
Network: Shows resource loading timing (HTML, JS bundles, CSS, API calls). Look for:
- Large bundles (>250KB gzipped) = slow load
- Render-blocking resources (synchronous scripts)
- Waterfall patterns (sequential loading instead of parallel)
-
Frames: 60fps = 16.67ms per frame. Green bars = smooth (under 16.67ms). Red bars = janky (over 16.67ms).
- Dropped frames = user sees stuttering
- Consistent red bars = performance problem
-
Main Thread (most important for React):
- Task (yellow): JavaScript execution (including React rendering)
- Rendering (purple): Recalculate styles, layout (browser work after React)
- Painting (green): Paint pixels, composite layers (GPU work)
- Idle (white): Main thread available (good!)
-
React Lanes (React 18+ concurrent features):
- Shows priority levels of React work
- High-priority work (user input) vs low-priority work (data fetching)
Identifying Long Tasks and Their Impact:
Main Thread Timeline (Chrome DevTools):
0ms |----[Long Task 287ms]----| β RED FLAG: Blocks main thread
βββ React render (123ms) β React component rendering
βββ Layout calculation (89ms) β Browser recalculating positions
βββ Data transformation (75ms) β Your business logic
Frames Timeline:
|--16ms--|--16ms--|--287ms---------|--16ms--| β Dropped 17 frames!
60fps 60fps 3.5fps (janky) 60fps
Long tasks (>50ms) cause:
- Frame drops (stuttering animations)
- Unresponsive UI (clicks don't register)
- Input lag (typing feels delayed)
- Poor user experience (users think app is broken)
Solutions for long tasks:
- Code splitting: Load less JavaScript upfront
- Debouncing/throttling: Reduce event handler frequency
- Web Workers: Offload heavy computation to background thread
- React Concurrent features: Time slicing (break work into chunks)
- Virtualization: Render only visible items
- Memoization: Skip unnecessary work
Why Did You Render: The Secret Weapon
// Install: npm install @welldone-software/why-did-you-render
import whyDidYouRender from '@welldone-software/why-did-you-render';
if (process.env.NODE_ENV === 'development') {
whyDidYouRender(React, {
trackAllPureComponents: true, // Track React.memo components
trackHooks: true, // Track custom hooks
logOnDifferentValues: true, // Show what changed
collapseGroups: true, // Cleaner console output
include: [/Dashboard/], // Only track specific components (optional)
exclude: [/^Connect/] // Exclude Redux connect HOCs (optional)
});
}
// Mark specific components to track
const Dashboard = () => { /* ... */ };
Dashboard.whyDidYouRender = true;
// Console output (extremely helpful!):
// Dashboard re-rendered because:
// β different objects that are equal by value:
// prev onClick: Ζ () { ... }
// next onClick: Ζ () { ... }
// β different objects that are equal by value:
// prev chartData: [{id: 1, ...}, {id: 2, ...}]
// next chartData: [{id: 1, ...}, {id: 2, ...}]
//
// Translation: onClick and chartData are new references (need useCallback/useMemo)
Performance Budget System: Preventing Regressions
// performance-budgets.json
{
"budgets": [
{
"metric": "component-render-time",
"component": "Dashboard",
"budget": 200,
"unit": "ms",
"severity": "error" // Fail CI if exceeded
},
{
"metric": "component-render-time",
"component": "DataTable",
"budget": 100,
"unit": "ms",
"severity": "warning" // Warn but don't fail
},
{
"metric": "bundle-size",
"entry": "main",
"budget": 250,
"unit": "KB"
},
{
"metric": "time-to-interactive",
"budget": 3500,
"unit": "ms"
}
]
}
// Automated performance testing (integrate into CI/CD)
import { render } from '@testing-library/react';
import { Profiler } from 'react';
test('Dashboard renders within performance budget', async () => {
let mountTime, updateTime;
const onRender = (id, phase, actualDuration) => {
if (phase === 'mount') mountTime = actualDuration;
if (phase === 'update') updateTime = actualDuration;
};
const { rerender } = render(
<Profiler id="Dashboard" onRender={onRender}>
<Dashboard data={mockData} />
</Profiler>
);
// Test initial render
expect(mountTime).toBeLessThan(200); // 200ms budget for mount
// Test update render
rerender(
<Profiler id="Dashboard" onRender={onRender}>
<Dashboard data={updatedMockData} />
</Profiler>
);
expect(updateTime).toBeLessThan(100); // 100ms budget for updates
});
Advanced Profiling: Code Splitting Analysis:
// Route-based splitting (lazy load entire routes)
const Dashboard = lazy(() => import('./Dashboard'));
const Settings = lazy(() => import('./Settings'));
function App() {
return (
<Suspense fallback={<PageSpinner />}>
<Routes>
<Route path="/dashboard" element={<Dashboard />} />
<Route path="/settings" element={<Settings />} />
</Routes>
</Suspense>
);
}
// Component-based splitting (lazy load heavy components)
const HeavyChart = lazy(() => import('./HeavyChart'));
function Dashboard() {
const [showChart, setShowChart] = useState(false);
return (
<div>
<button onClick={() => setShowChart(true)}>Show Chart</button>
{showChart && (
<Suspense fallback={<ChartSkeleton />}>
<HeavyChart />
</Suspense>
)}
</div>
);
}
// Measure code splitting impact:
// Before: main.js = 850KB, Time to Interactive = 6.2s
// After: main.js = 120KB, dashboard.chunk.js = 450KB, chart.chunk.js = 280KB
// Time to Interactive = 1.8s (71% improvement)
π Real-World Scenarioβ
Production Crisis: E-Learning Platform Performance Meltdown
Context: Online learning platform with video lectures, interactive quizzes, and real-time progress tracking. After launching a new "Live Class" feature, users reported catastrophic performance issues.
User Complaints:
- "Video player freezes every 2-3 seconds"
- "Can't type in chat - 5+ second delay"
- "Quiz questions take 10 seconds to load"
- "Browser tab crashes after 10 minutes"
- "Phone gets extremely hot during class"
Business Impact:
- 67% drop in completion rates
- 42% increase in support tickets
- Churn rate doubled (users canceling subscriptions)
- 1-star reviews mentioning "unusable app"
The Investigation: Step-by-Step Profiling
Phase 1: Initial Profiling with React DevTools
Joined a live class and opened React DevTools Profiler:
Recording: 10-second session during live class
Results:
- Total commits: 47 (should be ~10 for smooth experience)
- Total render time: 14,234ms across 47 commits
- Average commit time: 303ms (target: <16ms for 60fps)
- Components rendered: 2,847 total renders
- Frame rate: 12-15fps (target: 60fps)
Flamegraph (single commit):
LiveClassroom (8,934ms)
βββ VideoPlayer (156ms) β
Reasonable
βββ ChatPanel (7,234ms) β EXTREME
β βββ MessageList (6,789ms)
β β βββ Message (6.8ms Γ 1000 messages)
β βββ InputBox (445ms)
βββ ParticipantsList (892ms) β High
β βββ Participant (44ms Γ 20 participants)
βββ ProgressTracker (652ms) β High
βββ QuizProgress (145ms Γ 4.5 average)
Critical Findings:
- ChatPanel rendering 1000+ messages every 2 seconds (new message arrives)
- ParticipantsList re-rendering all participants when one raises hand
- ProgressTracker recalculating quiz stats on every message
Phase 2: Chrome DevTools Performance Analysis
Recorded Performance profile during chat interaction:
Performance Timeline (typing one message):
Main Thread:
0ms |--[Long Task 4,234ms]--|
βββ React render (2,456ms)
β βββ ChatPanel render (1,892ms)
β βββ Message components (564ms)
βββ Layout (1,234ms) β Re-layout entire message list
βββ Paint (434ms) β Re-paint everything
βββ Composite (110ms)
4234ms User sees their message
Frames:
|--16--|--16--|--4234ms--|--16--| β Massive frame drop
60fps 60fps 0.2fps 60fps
JavaScript Heap:
Start: 45 MB
Peak: 289 MB (during render)
End: 178 MB
GC pauses: 3 (totaling 456ms) β Garbage collection blocking
Memory Analysis revealed:
- 1000 Message components created on every new message
- Each Message holds references to user avatar, timestamp formatter, markdown parser
- Memory leak: Event listeners not cleaned up
- DOM nodes: 8,456 (target: <1,500 for smooth scrolling)
Phase 3: Why Did You Render Investigation
// Added to ChatPanel
ChatPanel.whyDidYouRender = true;
// Console output on new message:
WhyDidYouRender: ChatPanel re-rendered
Reason: different objects that are equal by value
Props changes:
messages: [1000 items] β [1001 items] β
Expected
onSendMessage: Ζ β Ζ β NEW function reference
currentUser: {id: 1, name: "John"} β {id: 1, name: "John"} β NEW object
settings: {theme: "dark", ...} β {theme: "dark", ...} β NEW object
participants: [20 items] β [20 items] β NEW array (even though same)
// Root cause: Parent component creating new references every render
The Problematic Code
// β BEFORE: Performance disaster
function LiveClassroom() {
const [messages, setMessages] = useState([]);
const [participants, setParticipants] = useState([]);
const [quizProgress, setQuizProgress] = useState({});
// WebSocket receiving 10-30 messages per second during active chat
useEffect(() => {
const ws = new WebSocket('wss://api.learn.com/live');
ws.onmessage = (event) => {
const data = JSON.parse(event.data);
if (data.type === 'message') {
setMessages(prev => [...prev, data.message]); // Triggers ChatPanel re-render
}
if (data.type === 'participant-update') {
setParticipants(data.participants); // New array reference
}
if (data.type === 'quiz-update') {
setQuizProgress(data.progress); // New object reference
}
};
}, []);
// NEW references created every render
const currentUser = {
id: userId,
name: userName,
avatar: userAvatar
};
const settings = {
theme: 'dark',
notifications: true,
autoScroll: true
};
// New function reference every render
const handleSendMessage = (text) => {
sendMessage(text);
};
// Expensive calculation on EVERY render
const quizStats = calculateQuizStatistics(quizProgress, participants);
return (
<div className="live-classroom">
<VideoPlayer />
{/* Re-renders on EVERY parent update */}
<ChatPanel
messages={messages}
currentUser={currentUser}
settings={settings}
onSendMessage={handleSendMessage}
/>
<ParticipantsList participants={participants} />
<ProgressTracker stats={quizStats} />
</div>
);
}
// ChatPanel - rendering ALL 1000+ messages
function ChatPanel({ messages, currentUser, settings, onSendMessage }) {
return (
<div className="chat">
<MessageList>
{/* Rendering 1000+ Message components on every new message */}
{messages.map(msg => (
<Message
key={msg.id}
message={msg}
currentUser={currentUser}
settings={settings}
/>
))}
</MessageList>
<InputBox onSend={onSendMessage} />
</div>
);
}
// Each Message doing expensive work
function Message({ message, currentUser, settings }) {
// Parsing markdown on EVERY render
const formattedText = parseMarkdown(message.text);
// Formatting timestamp on EVERY render
const timestamp = formatTimestamp(message.createdAt, settings.timezone);
return (
<div className="message">
<Avatar url={message.user.avatar} />
<div>
<div>{message.user.name} Β· {timestamp}</div>
<div dangerouslySetInnerHTML={{ __html: formattedText }} />
</div>
</div>
);
}
Performance Metrics Before Optimization:
- New message to screen: 4,234ms
- Frame rate during chat: 12fps
- Memory usage: 45MB β 289MB (6.4x increase)
- Garbage collections: 3 per message (456ms total pause time)
- DOM nodes: 8,456
- Component renders per message: 1,000+
- Time to Interactive: 8.7 seconds
- Battery drain: Severe (CPU at 100%)
The Solution: Comprehensive Optimization
// β
AFTER: Optimized with profiling insights
function LiveClassroom() {
const [messages, setMessages] = useState([]);
const [participants, setParticipants] = useState([]);
const [quizProgress, setQuizProgress] = useState({});
// Stable references with useMemo
const currentUser = useMemo(() => ({
id: userId,
name: userName,
avatar: userAvatar
}), [userId, userName, userAvatar]);
const settings = useMemo(() => ({
theme: 'dark',
notifications: true,
autoScroll: true
}), []); // Static settings
// Stable callback reference
const handleSendMessage = useCallback((text) => {
sendMessage(text);
}, []);
// Memoize expensive calculation
const quizStats = useMemo(
() => calculateQuizStatistics(quizProgress, participants),
[quizProgress, participants]
);
useEffect(() => {
const ws = new WebSocket('wss://api.learn.com/live');
ws.onmessage = (event) => {
const data = JSON.parse(event.data);
if (data.type === 'message') {
setMessages(prev => [...prev, data.message]);
}
if (data.type === 'participant-update') {
// Only update if actually different
setParticipants(prev => {
if (JSON.stringify(prev) === JSON.stringify(data.participants)) {
return prev; // Return same reference
}
return data.participants;
});
}
if (data.type === 'quiz-update') {
setQuizProgress(data.progress);
}
};
}, []);
return (
<div className="live-classroom">
<VideoPlayer />
<ChatPanelMemo
messages={messages}
currentUser={currentUser}
settings={settings}
onSendMessage={handleSendMessage}
/>
<ParticipantsListMemo participants={participants} />
<ProgressTrackerMemo stats={quizStats} />
</div>
);
}
// Memoized ChatPanel
const ChatPanelMemo = React.memo(ChatPanel);
// Virtualized message list (only render visible messages)
import { FixedSizeList as List } from 'react-window';
function ChatPanel({ messages, currentUser, settings, onSendMessage }) {
const listRef = useRef();
// Auto-scroll to bottom on new message
useEffect(() => {
if (settings.autoScroll && listRef.current) {
listRef.current.scrollToItem(messages.length - 1);
}
}, [messages.length, settings.autoScroll]);
// Render row callback
const Row = useCallback(({ index, style }) => (
<div style={style}>
<MessageMemo
message={messages[index]}
currentUser={currentUser}
settings={settings}
/>
</div>
), [messages, currentUser, settings]);
return (
<div className="chat">
{/* Only render 20-30 visible messages instead of all 1000+ */}
<List
ref={listRef}
height={600}
itemCount={messages.length}
itemSize={80}
width="100%"
>
{Row}
</List>
<InputBox onSend={onSendMessage} />
</div>
);
}
// Memoized Message component with custom comparison
const MessageMemo = React.memo(
Message,
(prevProps, nextProps) => {
// Only re-render if message content changed
return prevProps.message.id === nextProps.message.id &&
prevProps.message.text === nextProps.message.text;
}
);
function Message({ message, currentUser, settings }) {
// Memoize markdown parsing (expensive operation)
const formattedText = useMemo(
() => parseMarkdown(message.text),
[message.text]
);
// Memoize timestamp formatting
const timestamp = useMemo(
() => formatTimestamp(message.createdAt, settings.timezone),
[message.createdAt, settings.timezone]
);
return (
<div className="message">
<Avatar url={message.user.avatar} />
<div>
<div>{message.user.name} Β· {timestamp}</div>
<div dangerouslySetInnerHTML={{ __html: formattedText }} />
</div>
</div>
);
}
// Memoize ParticipantsList
const ParticipantsListMemo = React.memo(ParticipantsList);
function ParticipantsList({ participants }) {
return (
<div className="participants">
{participants.map(p => (
<ParticipantMemo key={p.id} participant={p} />
))}
</div>
);
}
const ParticipantMemo = React.memo(
Participant,
(prev, next) => prev.participant.id === next.participant.id &&
prev.participant.status === next.participant.status
);
// Memoize ProgressTracker
const ProgressTrackerMemo = React.memo(ProgressTracker);
Performance Metrics After Optimization:
- New message to screen: 34ms (99.2% improvement)
- Frame rate during chat: 58-60fps (383% improvement)
- Memory usage: 45MB β 67MB (stable, 76% less than before)
- Garbage collections: 0 per message (no blocking pauses)
- DOM nodes: 487 (94% reduction)
- Component renders per message: 1 (99.9% reduction)
- Time to Interactive: 1.8 seconds (79% improvement)
- Battery drain: Minimal (CPU at 15-20%)
Verification with React Profiler
Recording: Same 10-second session
Results:
- Total commits: 11 (78% reduction)
- Total render time: 287ms (98% improvement)
- Average commit time: 26ms (91% improvement)
- Components rendered: 34 total renders (99% reduction)
- Frame rate: 58-60fps
Flamegraph (single commit after optimization):
LiveClassroom (34ms)
βββ VideoPlayer (0ms) β
Skipped (memoized)
βββ ChatPanel (28ms) β
Fast
β βββ Message (28ms Γ 1 new message only)
βββ ParticipantsList (0ms) β
Skipped (memoized)
βββ ProgressTracker (0ms) β
Skipped (memoized)
Key Optimization Wins:
- Virtualization: Reduced DOM nodes from 8,456 to 487 (react-window)
- Memoization: Prevented 1,000+ unnecessary re-renders per message
- Stable references: useMemo/useCallback prevented cascading re-renders
- Custom comparison: Message only re-renders if content changed
- Computed value caching: Expensive calculations only run when dependencies change
Long-term Monitoring Setup
// Production monitoring with performance budgets
import { Profiler } from 'react';
function PerformanceMonitor({ children, componentName, budget = 100 }) {
const onRender = useCallback((id, phase, actualDuration) => {
if (actualDuration > budget) {
// Send alert to monitoring service
monitoring.trackSlowRender({
component: componentName,
duration: actualDuration,
budget,
exceeded: actualDuration - budget,
url: window.location.pathname,
timestamp: Date.now()
});
}
}, [componentName, budget]);
return (
<Profiler id={componentName} onRender={onRender}>
{children}
</Profiler>
);
}
// Usage
<PerformanceMonitor componentName="ChatPanel" budget={50}>
<ChatPanel {...props} />
</PerformanceMonitor>
</details>
<details> <summary><strong>βοΈ Trade-offs: Profiling Tool Selection</strong></summary>
Profiling Tool Selection
React DevTools Profiler:
- β Pros: React-specific insights, component-level detail, interaction tracking
- β Cons: Doesn't show browser-level work (layout, paint), limited to React operations
- Use when: Debugging component re-renders, identifying slow React components
Chrome DevTools Performance:
- β Pros: Complete browser pipeline, JavaScript execution detail, memory profiling
- β Cons: Overwhelming detail, harder to correlate with React components
- Use when: Investigating non-React bottlenecks (layout thrashing, long tasks)
Why Did You Render:
- β Pros: Pinpoints exact prop changes causing re-renders, easy to spot reference issues
- β Cons: Development only, adds overhead, can be noisy with many components
- Use when: Debugging unexpected re-renders, verifying memoization effectiveness
Lighthouse:
- β Pros: Real-world metrics (FCP, LCP, TTI, CLS), performance score, recommendations
- β Cons: Load-time focused, doesn't profile runtime interactions
- Use when: Measuring initial load performance, Core Web Vitals optimization
Decision Matrix:
| Problem | Best Tool | Why |
|---|---|---|
| Slow component render | React DevTools Profiler | Shows render times |
| Unnecessary re-renders | Why Did You Render | Shows prop changes |
| Janky scrolling | Chrome Performance | Shows layout/paint |
| Slow page load | Lighthouse | Shows load metrics |
| Memory leak | Chrome Memory Profiler | Shows heap snapshots |
| Large bundle | webpack-bundle-analyzer | Shows bundle composition |
Production vs Development Profiling
Development:
// β
Use development builds for profiling
// React DevTools only works with development builds
// More detailed warnings and error messages
// But: Slower than production (2-3x)
if (process.env.NODE_ENV === 'development') {
whyDidYouRender(React, {
trackAllPureComponents: true
});
}
Production:
// β
Profile production builds for real-world metrics
// Use profiling-enabled production build:
// npx react-scripts build --profile
// Or configure webpack:
module.exports = {
mode: 'production',
optimization: {
minimize: true,
moduleIds: 'named', // Enable for profiling
}
};
// Real User Monitoring (RUM)
import { Profiler } from 'react';
function ProductionProfiler({ children, id }) {
const onRender = (id, phase, actualDuration) => {
// Sample 1% of users to avoid overhead
if (Math.random() < 0.01) {
analytics.track('component-render', {
id,
phase,
duration: actualDuration
});
}
};
return (
<Profiler id={id} onRender={onRender}>
{children}
</Profiler>
);
}
Trade-off: Development profiling shows more detail but slower; production profiling shows real performance but less detail.
Optimization Overhead
Every optimization has a cost:
React.memo:
- Memory: Stores previous props
- CPU: Comparison on every render
- Code: Additional wrapper, harder to debug
// Overhead calculation
// Without memo: 0.5ms render
// With memo: 0.1ms comparison + 0.5ms render (if props changed)
// Benefit: 0.5ms saved when props unchanged
// Cost: 0.1ms added when props changed
// Breakeven: If props unchanged >16% of time, memo is worth it
useMemo:
- Memory: Stores memoized value and dependencies
- CPU: Dependency comparison on every render
// Without useMemo
const filtered = items.filter(i => i.active); // 10ms
// With useMemo
const filtered = useMemo(
() => items.filter(i => i.active),
[items]
); // 0.05ms (comparison) when items unchanged, 10ms when changed
// Benefit: 9.95ms saved when items unchanged
// Cost: 0.05ms added always
Virtualization (react-window):
- Complexity: More code, harder debugging
- Features: Lose native scrolling features
- Memory: Additional abstraction overhead
// Without virtualization: Simple code
<div>
{items.map(item => <Item key={item.id} item={item} />)}
</div>
// With virtualization: More complex
<FixedSizeList
height={600}
itemCount={items.length}
itemSize={80}
>
{Row}
</FixedSizeList>
// Trade-off:
// - Complexity increases
// - But: 1000 items render in 20ms instead of 2000ms
When to Virtualize:
- β Lists with >100 items
- β Each item >10ms to render
- β User scrolls frequently
- β Small lists (<50 items)
- β Items have varying heights (use VariableSizeList, more complex)
Profiling Frequency
Continuous profiling (every commit):
- β Pros: Catches regressions immediately
- β Cons: CI/CD overhead, flaky tests
Periodic profiling (weekly/monthly):
- β Pros: Less overhead, focused effort
- β Cons: Regressions caught later
Event-driven profiling (after reports):
- β Pros: Fixes actual problems
- β Cons: Reactive, not proactive
Best practice: Combine approaches:
// 1. Automated performance tests (catch major regressions)
test('Dashboard renders within budget', async () => {
const { renderTime } = await profileComponent(<Dashboard />);
expect(renderTime).toBeLessThan(200);
});
// 2. Monthly profiling sessions (deep dives)
// 3. Real user monitoring (catch production issues)
Premature Profiling
Profiling too early wastes time:
// β Profiling before building feature
// You don't know what will be slow yet
// β
Correct order:
// 1. Build feature
// 2. Basic manual testing
// 3. Profile if it feels slow
// 4. Optimize bottlenecks
// 5. Verify improvement
Exception: Profile early if:
- Working with known large datasets (>1000 items)
- Building performance-critical feature (real-time collaboration)
- Previous similar feature had performance issues
Synthetic vs Real User Metrics
Synthetic (Lighthouse, local profiling):
- β Pros: Controlled environment, reproducible, fast feedback
- β Cons: Doesn't reflect real-world conditions (devices, networks, usage patterns)
Real User (RUM - Real User Monitoring):
- β Pros: Actual user experience, diverse conditions, real interactions
- β Cons: Slower feedback, harder to debug, privacy concerns
Example discrepancy:
Synthetic (Developer MacBook Pro):
- Time to Interactive: 1.2s
- Frame rate: 60fps
- Smooth experience
Real User Monitoring (global average):
- Time to Interactive: 4.8s (4x slower)
- Frame rate: 28fps
- Janky experience
Reason: Low-end phones, slow 3G networks, battery saver mode
Solution: Profile on representative devices (not just developer machines).
π¬ Explain to Juniorβ
The Doctor Checkup Analogy
Profiling is like going to a doctor for a health checkup.
React DevTools Profiler = Blood test
- Shows what's happening inside (which components are "sick")
- Identifies slow components (high render times)
- Shows relationships (parent components affecting children)
Chrome DevTools = Full body scan
- Shows everything: bones (DOM structure), organs (JavaScript execution), blood flow (paint, layout)
- More detailed but harder to understand
Why Did You Render = Symptoms journal
- Logs exactly what changed and when
- "Your component re-rendered because props.onClick changed"
Step-by-Step: Your First Performance Investigation
Scenario: User reports "App is laggy when typing in search box"
Step 1: Reproduce the problem
// Open the app, type in search
// Feel the lag yourself - is it really slow?
Step 2: Open React DevTools Profiler
1. Open Chrome DevTools (F12)
2. Click "Profiler" tab (if not visible, install React DevTools extension)
3. Click record button (circle)
4. Type in the search box (reproduce the lag)
5. Click stop recording
Step 3: Read the Flamegraph
You'll see something like:
App (1,234ms)
βββ SearchBar (45ms) β Not the problem
βββ ResultsList (1,189ms) β THIS IS THE PROBLEM!
βββ ResultItem (11.8ms Γ 100 items)
Translation: ResultsList is taking 1.2 seconds!
Step 4: Understand why
// Look at the code
function ResultsList({ query }) {
const items = data.filter(item =>
item.name.includes(query)
);
return items.map(item => (
<ResultItem key={item.id} item={item} />
));
}
// Problem: Filtering 10,000 items on EVERY keystroke
// If user types "react" β 5 keystrokes = 5 filters = 50,000 iterations!
Step 5: Fix it
// Add useMemo to filter only when query changes
const items = useMemo(() =>
data.filter(item => item.name.includes(query)),
[query, data]
);
// Now: Only filters when query actually changes
Step 6: Verify the fix
Profile again:
App (67ms) β Down from 1,234ms!
βββ SearchBar (45ms)
βββ ResultsList (22ms) β Much faster!
βββ ResultItem (0.2ms Γ 100 items)
Success! 95% improvement
Beginner-Friendly Profiling Checklist
Before Profiling:
- I can reproduce the slow behavior
- I have React DevTools installed
- I'm using the development build (not production)
During Profiling:
- Record while doing the slow action
- Recording is short (3-10 seconds max)
- I only did one thing (don't click multiple things)
Reading Results:
- I found the flamegraph
- I can see which component is slowest (widest bar)
- I checked "Ranked" view (sorts by slowest first)
Common Patterns to Look For:
Pattern 1: "Everything re-renders when I change one thing"
// Problem: Parent creates new function every render
function Parent() {
const [count, setCount] = useState(0);
const handleClick = () => {}; // NEW function every render
return <Child onClick={handleClick} />;
}
// Solution: useCallback
const handleClick = useCallback(() => {}, []);
Pattern 2: "List gets slow with many items"
// Problem: Rendering 1000 items
<div>
{items.map(item => <Item key={item.id} item={item} />)}
</div>
// Solution: Virtualization
import { FixedSizeList } from 'react-window';
<FixedSizeList
height={600}
itemCount={items.length}
itemSize={50}
>
{Row}
</FixedSizeList>
Pattern 3: "Expensive calculation runs too often"
// Problem: Recalculating every render
function Component({ items }) {
const sorted = items.sort(...); // Runs every render
}
// Solution: useMemo
const sorted = useMemo(
() => items.sort(...),
[items]
);
Interview Answer Template
Question: "How do you identify and fix performance issues in React?"
Template Answer:
"I use a systematic approach to identify and fix performance issues:
First, I reproduce the problem and establish a baseline. I use React DevTools Profiler to record the slow interaction and identify which components are taking the most time.
Second, I analyze the flamegraph to find bottlenecks. I look for components with long render times, unnecessary re-renders, or components that render too frequently.
Third, I investigate the root cause. Common issues include:
- Components re-rendering when props haven't changed (missing memoization)
- Expensive calculations running on every render (missing useMemo)
- Unstable function references breaking child memoization (missing useCallback)
- Rendering too many DOM elements (needs virtualization)
Fourth, I apply targeted optimizations:
- React.memo for components with expensive renders
- useMemo for expensive calculations
- useCallback for stable function references
- react-window for long lists
Finally, I profile again to verify the improvement and ensure I didn't introduce regressions.
For example, I recently optimized a dashboard where the ChatPanel was re-rendering 1000+ messages on every new message. Using React DevTools Profiler, I identified that the problem was missing memoization and lack of virtualization. After adding React.memo and react-window, render time dropped from 4.2 seconds to 34ms - a 99% improvement.
I also set up performance budgets and automated testing to catch regressions early. Any component exceeding its budget triggers an alert in our monitoring system."
Quick Tools Reference:
| Task | Tool | How to Access |
|---|---|---|
| Find slow components | React DevTools Profiler | DevTools β Profiler tab β Record |
| See why component re-rendered | Why Did You Render | Console logs during development |
| Measure page load speed | Lighthouse | DevTools β Lighthouse tab β Generate report |
| Analyze bundle size | webpack-bundle-analyzer | Run build with analyzer plugin |
| Check frame rate | Chrome Performance | DevTools β Performance β Record β Check FPS |
| Find memory leaks | Chrome Memory | DevTools β Memory β Heap snapshot |
Golden Rules for Beginners:
- Always profile before optimizing - Don't guess what's slow
- Focus on the biggest wins - Optimize the slowest component first
- Measure the impact - Profile again after optimization to verify
- Don't optimize everything - Only optimize actual bottlenecks
- Use production-like data - Test with realistic data volumes
- Document your findings - Future you will thank you
</details>
Source: rendering-02-optimization.md