React makes it easy to build user interfaces, but building fast React apps requires understanding how React actually rerenders components and which performance patterns truly matter in production. This comprehensive guide covers all the essential React performance optimization techniques used by senior engineers.
Understanding Rerendering
Most performance concerns in React come from one thing: rerendering. Understanding how rerendering works is the foundation for all React performance optimization techniques.
What is Rerendering?
Rerendering occurs when React updates the UI to reflect changes in your application's state or props. React rerenders components in the following scenarios:
- State changes - When
useStateoruseReducerupdates state - Props changes - When parent components pass new prop values
- Context value changes - When context providers update their values
- Parent component rerenders - When a parent component rerenders, all its children rerender by default
The Rerendering Problem
Let's examine a common scenario where rerendering causes performance issues:
import { useState } from 'react'
import RenderTracker from './RenderTracker';
const RenderTrackerDemo = () => {
const [value, setValue] = useState("");
return (
<div className="p-2 border rounded">
<RenderTracker />
<input
className="border rounded p-1"
value={value}
onChange={(e) => setValue(e.target.value)}
/>
</div>
);
}
export default RenderTrackerDemoIn this example, every keystroke in the input field causes the parent component to rerender, which in turn forces the RenderTracker child component to rerender, even though it doesn't depend on the input value.
Why Rerendering Happens Twice in Development
You might notice that components rerender twice in development mode. This is because React runs in Strict Mode during development, which intentionally double-invokes components to help detect side effects. This behavior doesn't occur in production builds.
The Impact of Unnecessary Rerenders
Unnecessary rerenders can cause:
- Performance degradation - Especially with expensive computations
- Poor user experience - UI feels sluggish or unresponsive
- Increased memory usage - More objects created and garbage collected
- Battery drain - On mobile devices
Understanding the Component Tree
When a parent component rerenders, React's default behavior is to rerender all children in the component tree. This is by design and follows React's philosophy of declarative updates, but it can lead to performance issues if not managed properly.
function Parent() {
const [count, setCount] = useState(0);
return (
<div>
<button onClick={() => setCount(count + 1)}>Increment</button>
<ChildA /> {/* Rerenders when count changes */}
<ChildB /> {/* Rerenders when count changes, even if unrelated */}
</div>
);
}Key Takeaways
- Rerendering is React's default behavior - It's not a bug, it's a feature
- Parent rerenders trigger child rerenders - This is how React ensures UI consistency
- Not all rerenders are bad - Only unnecessary rerenders cause performance issues
- Performance optimization is about controlling rerenders - Not preventing them entirely
Memoization Techniques
Memoization is a computer programming optimization technique that speeds up your program by storing the result of a function's execution and reusing the cached result when the input to the function doesn't change.
In React, memoization helps prevent unnecessary rerenders and expensive recalculations. React provides three main memoization techniques: React.memo, useCallback, and useMemo.
What is Memoization?
Memoization is a caching technique where you cache the output value of a function and always return the same value as long as the input remains the same. If the input changes, the function recalculates and caches the new result.
Conceptual Example:
f(x) → y (cached)
If x remains the same → return cached y
If x changes to p → calculate new result z and cache itReact.memo: Memoizing Components
React.memo is a higher-order component that memoizes the result of a component. It only rerenders the component if its props have changed.
Example: Preventing Unnecessary Rerenders
import { useState } from "react";
import MemoizedCard from "./ProfileTracker";
const MemoizedProfileTracker = () => {
const [value, setValue] = useState("");
return (
<div className="p-2 border rounded">
<input
className="border rounded p-1"
value={value}
onChange={(e) => setValue(e.target.value)}
/>
<MemoizedCard name="John Doe" />
</div>
);
};
export default MemoizedProfileTracker;Result: Without memo, the ProfileCard component rerenders on every keystroke. With memo, it only rerenders if the name prop changes.
How React.memo Works
React.memo performs a shallow comparison of props. If all props are the same (by reference for objects/arrays, by value for primitives), React skips rerendering the component.
useCallback: Memoizing Functions
useCallback is a hook that memoizes function references. It's essential when passing functions as props to memoized components.
The Problem: Inline Functions Break Memoization
When you pass an inline function to a memoized component, it creates a new function reference on every render, breaking the memoization.
Solution: useCallback
import { useCallback, useState } from "react";
import Child from "./Child";
const ChildDemo = () => {
const [count, setCount] = useState(0);
const handleClick = useCallback(() => {
console.log("Child Clicked!");
}, []);
console.log("App Rendered");
return (
<div className="p-2 border rounded">
<p>Count: {count}</p>
<div className="space-x-2">
<button onClick={() => setCount(count + 1)}>Increment</button>
<Child onClick={handleClick} />
</div>
</div>
);
};
export default ChildDemo;Result: Without useCallback, the Child component rerenders every time the parent rerenders because a new function reference is created. With useCallback, the function reference is stable, so Child only rerenders when its props actually change.
useMemo: Memoizing Values
useMemo is a hook that memoizes the result of expensive computations. It only recalculates when its dependencies change.
Problem: Expensive Calculations on Every Render
Without useMemo, expensive operations like sorting large arrays run on every render, even when the data hasn't changed.
Solution: useMemo
import { useState } from "react";
import { getUsers } from "../../../utils";
import Users from "./Users";
const UsersSortingDemo = () => {
const [count, setCount] = useState(0);
const [users] = useState(() => getUsers()); // assume it returns 10,000 users
return (
<>
<p>{count}</p>
<button onClick={() => setCount((c) => c + 1)}>Increment</button>
<Users list={users} />
</>
);
};
export default UsersSortingDemo;Result: When you click the increment button, the Users component rerenders, but the expensive sorting operation only runs if the list prop changes. Without useMemo, sorting would run on every render.
Important: Avoid Mutation
Always create a copy before sorting to avoid mutating the original array:
// ❌ Bad: Mutates original array
const sorted = list.sort((a, b) => a.localeCompare(b));
// ✅ Good: Creates copy first
const sorted = [...list].sort((a, b) => a.localeCompare(b));When to Use Each Technique
Use React.memo when:
- Component receives props that don't change often
- Component is expensive to render
- Parent rerenders frequently but child props are stable
Use useCallback when:
- Passing functions to memoized components
- Functions are dependencies in other hooks
- Function identity matters for child components
Use useMemo when:
- Performing expensive calculations (sorting, filtering, transformations)
- Creating objects/arrays that are used as dependencies
- Computing derived values from props or state
Common Pitfalls
1. Over-memoization
Don't memoize everything. Memoization has its own overhead:
// ❌ Unnecessary memoization
const value = useMemo(() => count * 2, [count]); // Simple multiplication
// ✅ Only memoize expensive operations
const expensiveValue = useMemo(() => {
return heavyComputation(data);
}, [data]);2. Incorrect Dependencies
Always include all dependencies in dependency arrays:
// ❌ Missing dependency
const result = useMemo(() => {
return data.filter(item => item.category === category);
}, [data]); // Missing 'category'
// ✅ Correct dependencies
const result = useMemo(() => {
return data.filter(item => item.category === category);
}, [data, category]);3. Memoizing Primitives
Don't memoize simple primitive values:
// ❌ Unnecessary
const doubled = useMemo(() => count * 2, [count]);
// ✅ Just compute directly
const doubled = count * 2;Best Practices
- Profile first - Use React DevTools Profiler to identify actual performance issues
- Memoize strategically - Only memoize components/values that are actually expensive
- Keep dependencies accurate - Always include all dependencies in dependency arrays
- Avoid premature optimization - Don't memoize until you've identified a performance problem
- Consider React Compiler - In React 19+, the compiler can handle many memoization cases automatically
Derived State Anti-pattern
Derived state is any value that can be computed from existing state or props. Storing derived state in React state is a common anti-pattern that causes unnecessary rerenders and synchronization issues.
What is Derived State?
A derived state is any value that can be computed from existing state or props. Examples include:
- Filtered lists - Computed from a list and filter criteria
- Cart totals - Computed from cart items and their prices
- Text length - Computed from text content
- Boolean flags - Computed from other values (e.g.,
isAdultfromage)
The Anti-pattern: Storing Derived State
Here's a common mistake developers make:
import { useEffect, useState } from "react";
const Cart = ({ items }) => {
const [total, setTotal] = useState(0);
useEffect(() => {
const sum = items.reduce((acc, item) => acc + item.price, 0);
setTotal(sum);
}, [items]);
return <h2>Total: {total}</h2>;
};
export default Cart;Problems with This Approach
- Unnecessary rerenders - The component rerenders twice: once when
itemschanges, and again whentotalstate updates - Synchronization issues - You must manually keep
totalin sync withitems - Extra complexity - More code to maintain and more potential for bugs
- Performance overhead - Additional state updates and effect executions
The Correct Approach: Compute Directly
Instead of storing derived state, compute it directly:
function BetterCart({ items }) {
const total = items.reduce((acc, item) => acc + item.price, 0);
return <h2>Total: {total}</h2>;
}
export default BetterCart;Benefits:
- ✅ No extra state to manage
- ✅ Always in sync with source data
- ✅ Simpler code
- ✅ Fewer rerenders
Common Examples
Example 1: Filtered Lists
import { useMemo } from "react";
// ✅ Good: Compute directly with useMemo for expensive operations
export function Users({ list }) {
const activeUsers = useMemo(() => {
console.log("Filtering expensive list…");
return list.filter((user) => user.active);
}, [list]);
return (
<>
<h2>Active Users: {activeUsers.length}</h2>
{activeUsers.map((user) => (
<div key={user.id}>{user.name}</div>
))}
</>
);
}Example 2: Boolean Flags
// ❌ Bad: Storing derived boolean in state
export function Actor({ age }) {
const [isAdult, setIsAdult] = useState(false);
useEffect(() => {
setIsAdult(age >= 18);
}, [age]);
return <>{isAdult ? <p>Adult</p> : <p>Minor</p>}</>;
}
// ✅ Good: Compute directly
// function Actor({ age }) {
// const isAdult = age >= 18;
// return <>{isAdult ? <p>Adult</p> : <p>Minor</p>}</>;
// }Example 3: Form Validation
// ❌ Bad: Storing validation state
export function Form({ name, email }) {
const [isValid, setIsValid] = useState(false);
useEffect(() => {
setIsValid(name !== "" && email.includes("@"));
}, [name, email]);
return <>{isValid ? <p>All Good</p> : <p>Bad!!</p>}</>;
}
// ✅ Good: Compute directly
// function Form({ name, email }) {
// const isValid = name !== "" && email.includes("@");
// return <>{isValid ? <p>All Good</p> : <p>Bad!!</p>}</>;
// }When to Use useMemo for Derived Values
For expensive computations, use useMemo to cache the result:
function ExpensiveComponent({ largeDataSet }) {
// Expensive operation: filtering 100,000+ items
const filteredData = useMemo(() => {
return largeDataSet
.filter(item => item.category === 'active')
.sort((a, b) => a.date - b.date)
.map(item => transformItem(item));
}, [largeDataSet]);
return <DataDisplay data={filteredData} />;
}Key Point: useMemo is for performance optimization, not for storing derived state. The value is still computed, just cached.
Best Practices
- Compute directly - Calculate derived values in the render function
- Use useMemo for expensive operations - Cache results of heavy computations
- Avoid useEffect for derived state - Don't use effects to sync derived values
- Keep it simple - Simpler code is easier to maintain and less error-prone
- Profile performance - Only optimize when you've identified actual issues
Key Rules:
- Derived state should not be stored in React state
- Compute values directly from props or state
- Use
useMemofor expensive computations, not for storing state - Avoid
useEffectfor synchronizing derived values
Remember: If you can compute it, don't store it. This principle will help you write more performant and maintainable React code.
Debouncing
Debouncing is a technique that delays the execution of a function until a specified time has passed since the last time it was invoked. It's essential for optimizing expensive operations like API calls, search functionality, and form validations.
What is Debouncing?
Debouncing delays function execution by a specified duration. If the function is called again before the delay expires, the timer resets. The function only executes after the user stops triggering it for the specified duration.
Key Concept: Debouncing doesn't prevent events from firing—it controls when the function executes in response to those events.
The Problem: Too Many API Calls
Consider a search input that makes API calls on every keystroke. Typing "react" triggers 5 API calls (r, e, a, c, t), which is expensive, slow, and wasteful.
Solution: Custom useDebounce Hook
Here's a reusable useDebounce hook:
import { useEffect, useState } from "react";
export function useDebounce(value, delay = 500) {
const [debouncedValue, setDebouncedValue] = useState(value);
useEffect(() => {
const id = setTimeout(() => {
setDebouncedValue(value);
}, delay);
return () => clearTimeout(id);
}, [value, delay]);
return debouncedValue;
}How It Works
- Initial value -
debouncedValuestarts with the inputvalue - Timer setup - When
valuechanges, a timer is set fordelaymilliseconds - Reset on change - If
valuechanges again before the timer expires, the previous timer is cleared and a new one starts - Update after delay - Only after the delay expires without new changes does
debouncedValueupdate
Using useDebounce in Search
import { useEffect, useState } from "react";
import { useDebounce } from "./hooks/useDebounce";
export default function SearchBox() {
const [query, setQuery] = useState("");
const debouncedQuery = useDebounce(query, 600);
useEffect(() => {
if (!debouncedQuery) return;
console.log("API Call with:", debouncedQuery);
// Simulated API:
// fetch(`/api?q=${debouncedQuery}`)
}, [debouncedQuery]);
return (
<div>
<h3>Search User</h3>
<input
className="border rounded p-1"
type="text"
value={query}
placeholder="Type to search…"
onChange={(e) => setQuery(e.target.value)}
/>
</div>
);
}Result: Typing "react" now triggers only 1 API call (after 600ms of no typing), instead of 5.
Visual Example
User types: "r" → "re" → "rea" → "reac" → "react"
Timeline: |----600ms----|----600ms----|----600ms----|----600ms----|----600ms----| API callEach keystroke resets the timer. Only after 600ms of no typing does the API call execute.
Choosing the Right Delay
The optimal delay depends on your use case:
- Search inputs: 300-600ms (balance between responsiveness and API calls)
- Form validation: 500-1000ms (users expect immediate feedback)
- Resize handlers: 100-250ms (smooth UI updates)
- API calls: 500-1000ms (reduce server load)
Debouncing vs Throttling
Debouncing: Execute after a pause in activity
- Best for: Search inputs, form validation, API calls
- Example: Wait 500ms after user stops typing
Throttling: Execute at most once per time period
- Best for: Scroll handlers, resize handlers, mouse move
- Example: Execute at most once every 100ms
Best Practices
- Always cleanup timers - Use
clearTimeoutin the cleanup function - Choose appropriate delays - Balance user experience with performance
- Show loading states - Indicate when debouncing is active
- Handle edge cases - Consider empty values, rapid changes
- Test thoroughly - Ensure debouncing works correctly in all scenarios
Throttling
Throttling is a technique that limits how often a function can be executed. Unlike debouncing (which waits for a pause), throttling ensures a function executes at most once within a specified time period, regardless of how many times it's called.
What is Throttling?
Throttling limits function execution to at most once per specified time period. If the function is called multiple times within that period, only the first call (or last, depending on implementation) executes, and subsequent calls are ignored until the time period expires.
Key Concept: Throttling ensures regular, controlled execution, while debouncing waits for activity to stop.
The Problem: Too Many Function Calls
Consider a scroll handler that updates UI on every scroll event. Scroll events fire dozens of times per second, causing performance degradation, janky scrolling, unnecessary rerenders, and battery drain on mobile devices.
Solution: Custom useThrottle Hook
Here's a reusable useThrottle hook:
import { useEffect, useRef, useState } from "react";
export function useThrottle(value, delay = 300) {
const [throttledValue, setThrottledValue] = useState(value);
const lastExecuted = useRef(Date.now());
useEffect(() => {
const handler = setTimeout(() => {
const now = Date.now();
if (now - lastExecuted.current >= delay) {
console.log("Do DOM Manipulation");
setThrottledValue(value);
lastExecuted.current = now;
}
}, delay - (Date.now() - lastExecuted.current));
return () => clearTimeout(handler);
}, [value, delay]);
return throttledValue;
}How It Works
- Track last execution - Store timestamp of last update
- Check time elapsed - Compare current time with last execution
- Immediate or delayed - Update immediately if enough time passed, otherwise schedule
- Cleanup - Clear scheduled timer if value changes
Using useThrottle for Scroll
import { useEffect, useState } from "react";
import { useThrottle } from "./hooks/useThrottle";
export default function ScrollTracker() {
const [scrollY, setScrollY] = useState(0);
const throttledY = useThrottle(scrollY, 3000);
useEffect(() => {
const handleScroll = () => {
setScrollY(window.scrollY);
};
window.addEventListener("scroll", handleScroll);
return () => window.removeEventListener("scroll", handleScroll);
}, []);
return (
<div className="border h-48 p-1">
<h2 className="text-xl">Scroll Position (Throttled)</h2>
<p>{throttledY}</p>
</div>
);
}Result: The scroll position updates at most once every 3 seconds, dramatically reducing DOM manipulations and improving scroll performance.
Visual Example
Time: 0ms 50ms 100ms 150ms 200ms 250ms 300ms
Events: |------|------|------|------|------|------|
Call1 Call2 Call3 Call4 Call5 Call6 Call7
Exec Skip Exec Skip Skip Exec SkipWith 100ms throttling, only calls at 0ms, 100ms, and 300ms execute.
Choosing the Right Delay
The optimal throttle delay depends on your use case:
- Scroll handlers: 100-250ms (smooth but not excessive)
- Resize handlers: 250-500ms (resize is less frequent)
- Mouse move: 16ms (~60fps for smooth animations)
- API calls: 1000ms+ (reduce server load)
- Input handlers: Usually use debouncing instead
Throttling vs Debouncing
| Aspect | Throttling | Debouncing |
|---|---|---|
| Execution | At most once per period | After pause in activity |
| Use case | Scroll, resize, mouse move | Search, form validation |
| Timing | Regular intervals | After user stops |
| Example | Update every 100ms | Update 500ms after last input |
When to use throttling:
- Events that fire continuously (scroll, resize, mousemove)
- You need regular updates at controlled intervals
- Smooth animations or UI updates
When to use debouncing:
- User input (search, typing)
- You want to wait for activity to stop
- API calls that should wait for complete input
Best Practices
- Choose appropriate delays - Balance responsiveness with performance
- Clean up timers - Always clear timeouts in cleanup functions
- Use for continuous events - Throttle scroll, resize, mousemove
- Consider requestAnimationFrame - For animations,
requestAnimationFramemay be better - Test performance - Profile to ensure throttling improves performance
React Compiler
React 19 introduces the React Compiler, a revolutionary feature that automatically memoizes components, stabilizes function references, and prevents unnecessary rendering without requiring manual useMemo, useCallback, or React.memo from developers.
What is React Compiler?
The React Compiler is a build-time optimization tool that automatically applies memoization to your React components. It analyzes your code and automatically:
- Memoizes component outputs
- Stabilizes function references
- Prevents unnecessary rerenders
- Optimizes expensive computations
Key Benefit: You write less code, and React optimizes more automatically.
Before React Compiler
In React 18 and earlier, developers had to manually optimize performance:
import { memo, useMemo, useCallback } from 'react';
function ProductCard({ product, onAddToCart }) {
// Manual memoization required
const discountedPrice = useMemo(() => {
return product.price * 0.9;
}, [product.price]);
const handleClick = useCallback(() => {
onAddToCart(product.id);
}, [product.id, onAddToCart]);
return (
<div>
<h3>{product.name}</h3>
<p>Price: ${discountedPrice}</p>
<button onClick={handleClick}>Add to Cart</button>
</div>
);
}
// Manual memoization required
export default memo(ProductCard);Problems:
- Lots of boilerplate code
- Easy to forget optimization
- Dependency arrays can be error-prone
- Over-optimization or under-optimization
After React Compiler
With React Compiler, you write clean, simple code:
function ProductCard({ product, onAddToCart }) {
// No useMemo needed - compiler handles it
const discountedPrice = product.price * 0.9;
// No useCallback needed - compiler handles it
const handleClick = () => {
onAddToCart(product.id);
};
return (
<div>
<h3>{product.name}</h3>
<p>Price: ${discountedPrice}</p>
<button onClick={handleClick}>Add to Cart</button>
</div>
);
}
// No memo() needed - compiler handles it
export default ProductCard;Benefits:
- Cleaner, more readable code
- Automatic optimization
- No dependency arrays to manage
- Consistent performance improvements
How React Compiler Works
The React Compiler operates at build time:
- Code Analysis - Analyzes your React components during build
- Automatic Memoization - Identifies what should be memoized
- Function Stabilization - Stabilizes function references automatically
- Component Memoization - Wraps components with memoization logic
Internal Process
Your Code → React Compiler → Optimized Code → BundleThe compiler transforms your code to include memoization automatically, similar to how Babel transforms JSX.
Installation and Setup
Step 1: Install the Plugin
# Using npm
npm install -D babel-plugin-react-compiler
# Using yarn
yarn add -D babel-plugin-react-compiler
# Using pnpm
pnpm add -D babel-plugin-react-compilerStep 2: Configure Your Build Tool
For Vite
// vite.config.js
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [
react({
babel: {
plugins: [
['babel-plugin-react-compiler', {}],
],
},
}),
],
});For Next.js
// next.config.js
module.exports = {
compiler: {
reactCompiler: true,
},
};What Gets Optimized
1. Component Memoization
// Automatically memoized
function UserProfile({ user }) {
return <div>{user.name}</div>;
}2. Function References
// Automatically stabilized
function Button({ onClick, label }) {
return <button onClick={onClick}>{label}</button>;
}3. Expensive Computations
// Automatically memoized
function DataTable({ data }) {
const sortedData = data.sort((a, b) => a.name.localeCompare(b.name));
return <table>{/* render table */}</table>;
}Opting Out of Optimization
Sometimes you may want to opt out of automatic optimization for specific components:
function SpecialComponent({ data }) {
"use no memo"; // Opt out directive
// This component won't be automatically memoized
return <div>{data}</div>;
}When to opt out:
- Component has side effects that require rerenders
- Manual control is needed for specific cases
- Debugging optimization issues
Recommendation: Only opt out if you encounter specific issues. In most cases, let the compiler optimize.
Limitations and Considerations
1. React 19+ Required
React Compiler works best with React 19+. It can work with React 17 and 18, but React 19 provides the best experience.
2. Not a Magic Solution
React Compiler doesn't replace all optimization techniques:
- Virtualization - Still needed for large lists
- Code Splitting - Still needed for bundle size
- Context Optimization - Still needed for context splitting
- Lazy Loading - Still needed for on-demand loading
Best Practices
- Use React 19+ - Get the best compiler experience
- Write clean code - Let the compiler optimize
- Remove manual optimizations - Trust the compiler
- Profile performance - Verify improvements
- Opt out only when needed - Use
"use no memo"sparingly
Code Splitting and Lazy Loading
Code splitting and lazy loading are essential techniques for optimizing React applications. They allow you to load only the code needed for the current view, reducing initial bundle size and improving time to interactive.
The Problem: Large Bundles
As React applications grow, bundle sizes increase:
- Large bundles - All code loaded upfront
- Slow initial load - Users wait for everything to download
- Poor performance - Especially on slow networks
- Wasted bandwidth - Loading code that may never be used
Solution: React.lazy and Suspense
React provides React.lazy for dynamic imports and Suspense for loading states.
Basic Lazy Loading
import React, { Suspense, useState } from "react";
import Light from "./Light";
const Heavy = React.lazy(() => import("./Heavy"));
const LazyLoader = () => {
const [show, setShow] = useState(false);
return (
<div style={{ fontFamily: "system-ui, sans-serif", padding: 20 }}>
<h1>Lazy Demo</h1>
<p>
Heavy component is loaded on demand via{" "}
<code>React.lazy()</code>.
</p>
<Light />
<button onClick={() => setShow((s) => !s)} style={{ margin: 10 }}>
{show ? "Hide Heavy" : "Show Heavy"}
</button>
<Suspense
fallback={
<div style={{ padding: 20 }}>Loading heavy component…</div>
}
>
{show && <Heavy />}
</Suspense>
</div>
);
};
export default LazyLoader;Benefits:
HeavyComponentonly loads when needed- Smaller initial bundle
- Faster initial load time
- Better user experience
Comparison: In NonLazyLoader, the Heavy component is bundled and loaded immediately. In LazyLoader, it's only loaded when the user clicks "Show Heavy", reducing initial bundle size.
How React.lazy Works
React.lazy takes a function that returns a dynamic import:
const MyComponent = lazy(() => import('./MyComponent'));Behind the scenes:
- Dynamic import - Creates a separate chunk
- Promise-based - Returns a promise that resolves to the component
- Code splitting - Webpack/Vite automatically splits the code
- On-demand loading - Component loads when first rendered
Suspense for Loading States
Suspense provides a fallback UI while lazy components load:
<Suspense fallback={<LoadingSpinner />}>
<LazyComponent />
</Suspense>Route-Based Code Splitting
One of the most common use cases is splitting by routes:
import { lazy, Suspense } from 'react';
import { BrowserRouter, Routes, Route } from 'react-router-dom';
const Home = lazy(() => import('./pages/Home'));
const About = lazy(() => import('./pages/About'));
const Contact = lazy(() => import('./pages/Contact'));
function App() {
return (
<BrowserRouter>
<Suspense fallback={<div>Loading page...</div>}>
<Routes>
<Route path="/" element={<Home />} />
<Route path="/about" element={<About />} />
<Route path="/contact" element={<Contact />} />
</Routes>
</Suspense>
</BrowserRouter>
);
}Result: Each route loads only when navigated to, reducing initial bundle size significantly.
Best Practices
- Lazy load routes - Split by route for maximum impact
- Lazy load heavy components - Components with large dependencies
- Lazy load modals/dialogs - Load only when opened
- Provide good fallbacks - User-friendly loading states
- Preload on interaction - Preload on hover/focus
- Monitor bundle size - Use bundle analyzers
- Test on slow networks - Verify improvements
When to Use Lazy Loading
Good Candidates
- ✅ Route components
- ✅ Heavy third-party libraries
- ✅ Modals and dialogs
- ✅ Charts and visualizations
- ✅ Admin panels
- ✅ Features behind feature flags
Not Good Candidates
- ❌ Small, frequently used components
- ❌ Components needed immediately
- ❌ Critical above-the-fold content
- ❌ Components with minimal dependencies
Component Isolation
Component isolation is a best practice that involves strategically using memoization to create render boundaries, preventing unnecessary rerenders of child components when parent components update.
The Problem: Cascading Rerenders
When a parent component rerenders, all its children rerender by default. This can cause performance issues:
import Revenue from "./Revenue";
import UserCard from "./UserCard";
import Visitors from "./Visitors";
function Dashboard({ user, stats }) {
return (
<>
<UserCard user={user} />
<Revenue stats={stats} />
<Visitors />
</>
);
}
export default Dashboard;Problem: If stats changes, all three child components rerender, even though:
UserCardonly depends onuserVisitorsdoesn't depend on any props
Solution: Component Isolation with React.memo
Isolate components by wrapping them with React.memo:
import {memo} from "react";
import Revenue from "./Revenue";
import UserCard from "./UserCard";
import Visitors from "./Visitors";
const MUserCard = memo(UserCard);
const MRevenue = memo(Revenue);
const IsolatedDashboard = ({ user, stats }) => {
return (
<>
<MUserCard user={user} />
<MRevenue stats={stats} />
<Visitors />
</>
);
};
export default IsolatedDashboard;Result: With IsolatedDashboard, when stats changes, only MRevenue rerenders. MUserCard and Visitors don't rerender unnecessarily.
Understanding Render Boundaries
Render boundaries are points in your component tree where rerenders stop propagating:
Parent (rerenders)
├─ MemoizedChildA (doesn't rerender - boundary)
│ └─ GrandchildA (doesn't rerender)
└─ MemoizedChildB (doesn't rerender - boundary)
└─ GrandchildB (doesn't rerender)When Parent rerenders, MemoizedChildA and MemoizedChildB act as boundaries, preventing rerenders of their subtrees if props haven't changed.
When to Isolate Components
Good Candidates for Isolation
- Expensive components - Components with heavy rendering logic
- Stable props - Components that receive props that don't change often
- Leaf components - Components at the bottom of the tree
- Frequently rerendered parents - When parent rerenders often but child props are stable
Not Good Candidates
- Simple components - Very lightweight components
- Frequently changing props - If props change often, memoization adds overhead
- Components with many props - Shallow comparison can be expensive
- Components that always rerender - If props always change, memoization is useless
Best Practices
- Isolate strategically - Don't memoize everything, only what benefits
- Profile first - Use React DevTools to identify actual issues
- Stable props - Ensure props are stable (use
useCallback/useMemoif needed) - Test thoroughly - Verify isolation works as expected
- Document decisions - Comment why components are isolated
Context Optimization
React Context is powerful for sharing state across components, but it can cause massive rerenders if not optimized properly. When context value changes, all consuming components rerender, which can significantly impact performance.
The Problem: Context Rerenders
Context creates a chain of rerenders. When context value changes, all components consuming that context rerender:
import { createContext, useState } from 'react';
const AppContext = createContext();
function AppProvider({ children }) {
const [user, setUser] = useState(null);
const [theme, setTheme] = useState('light');
// ❌ Bad: Single context with multiple values
const value = {
user,
theme,
setUser,
setTheme,
};
return (
<AppContext.Provider value={value}>
{children}
</AppContext.Provider>
);
}Problem: When theme changes, all components consuming user also rerender unnecessarily.
Solution 1: Split Contexts
Split contexts by concern to minimize rerenders:
import { createContext, useState } from 'react';
const UserContext = createContext();
function UserProvider({ children }) {
const [user, setUser] = useState(null);
return (
<UserContext.Provider value={{ user, setUser }}>
{children}
</UserContext.Provider>
);
}
export { UserContext, UserProvider };Benefits:
- Components only rerender when their specific context changes
- Better performance
- Clearer separation of concerns
Solution 2: Memoize Context Value
Memoize the context value object to prevent unnecessary rerenders:
import { useMemo, useState } from 'react';
import { UserContext } from './UserContext';
function UserProvider({ children }) {
const [user, setUser] = useState(null);
// Memoize the value object
const value = useMemo(
() => ({ user, setUser }),
[user]
);
return (
<UserContext.Provider value={value}>
{children}
</UserContext.Provider>
);
}Why: Without memoization, a new object is created on every render, causing all consumers to rerender even if user hasn't changed.
Solution 3: Context Scope Optimization
Don't wrap the entire app if context is only needed in part of the tree:
// ❌ Bad: Wrapping entire app
function App() {
return (
<UserProvider>
<EntireApp /> {/* User context not needed everywhere */}
</UserProvider>
);
}Best Practices
- Split by concern - Separate contexts for different concerns
- Scope appropriately - Only wrap components that need context
- Memoize values - Use
useMemofor context value objects - Separate state/setters - Split state and dispatch contexts
- Use selectors - Subscribe to specific parts of context
- Profile performance - Measure impact of context optimizations
Virtualization
Virtualization is a technique that renders only the items visible on the screen, dramatically improving performance when dealing with large lists. Instead of rendering thousands of items, you render only what's visible plus a small buffer.
The Problem: Rendering Large Lists
Consider a list with 10,000 items:
function UserList({ users }) {
return (
<ul>
{users.map(user => (
<li key={user.id}>
{user.name} - {user.email}
</li>
))}
</ul>
);
}Problems:
- 10,000 DOM nodes - Massive memory usage
- Slow initial render - Takes seconds to render
- Janky scrolling - Browser struggles with so many elements
- Poor performance - Scripting and rendering take 8+ seconds
What is Virtualization?
Virtualization renders only the visible items plus a small buffer. As you scroll, items are dynamically added and removed from the DOM.
Concept:
- Render only visible items (e.g., 10-20 items)
- Calculate scroll position
- Dynamically render items as user scrolls
- Remove off-screen items from DOM
Solution: Using react-window
react-window is a popular library for implementing virtualization in React.
Installation
npm install react-windowBasic Example
import { useState } from "react";
import NonVirtualList from "./NonVirtualList";
import VirtualList from "./VirtualList";
import { useFakeUsers } from "./data";
const VirtualizationDemo = () => {
const users = useFakeUsers(50000);
const [mode, setMode] = useState("virtual"); // "virtual" or "non-virtual"
return (
<div style={{ padding: 20, fontFamily: "system-ui, sans-serif" }}>
<h1>List Virtualization Demo</h1>
<div style={{ marginBottom: 12 }}>
<button
className="underline cursor-pointer"
onClick={() => setMode("virtual")}
disabled={mode === "virtual"}
>
Use virtual list
</button>
<button
className="underline cursor-pointer"
onClick={() => setMode("non-virtual")}
disabled={mode === "non-virtual"}
style={{ marginLeft: 8 }}
>
Use non-virtual list
</button>
</div>
<div style={{ marginBottom: 8, color: "#fff" }}>
Current mode: <strong>{mode}</strong> — Users: {users.length}
</div>
{mode === "virtual" ? (
<VirtualList users={users} height={600} itemHeight={80} />
) : (
<NonVirtualList users={users} />
)}
</div>
);
};
export default VirtualizationDemo;Result: With virtualization, only ~12-15 items are rendered at a time, regardless of list size. Without virtualization, all 50,000 items are rendered, causing severe performance issues.
Performance Comparison
Without Virtualization
10,000 items:
- DOM nodes: 10,000
- Initial render: ~8 seconds
- Scripting: ~3.3 seconds
- Rendering: ~2.4 seconds
- Scroll performance: Janky, sluggishWith Virtualization
10,000 items (only ~15 rendered):
- DOM nodes: ~15
- Initial render: ~0.1 seconds
- Scripting: ~0.5 seconds
- Rendering: ~0.3 seconds
- Scroll performance: Smooth, responsiveImprovement: 80x faster initial render, smooth scrolling.
When to Use Virtualization
Good Candidates
- ✅ Large lists (100+ items)
- ✅ Long tables
- ✅ Infinite scroll
- ✅ Data grids
- ✅ Chat message lists
- ✅ Product catalogs
Not Good Candidates
- ❌ Small lists (< 50 items)
- ❌ Lists with complex nested structures
- ❌ Lists that need all items in DOM
- ❌ Lists with frequent reordering
Best Practices
- Use for large lists - 100+ items benefit significantly
- Choose appropriate item size - Accurate sizing improves performance
- Add overscan - Render a few extra items for smooth scrolling
- Memoize row components - Use
React.memofor row components - Handle dynamic heights - Use
VariableSizeListwhen needed - Test scroll performance - Verify smooth scrolling
Concurrent Rendering
React 18 introduced concurrent features that allow React to keep the UI responsive during expensive updates. useTransition and useDeferredValue are hooks that mark certain updates as non-urgent, allowing React to prioritize more important updates.
The Problem: Blocking Updates
When you type in a search box that filters a large list, the UI can freeze:
function SearchBox() {
const [query, setQuery] = useState('');
const filteredItems = items.filter(item =>
item.name.toLowerCase().includes(query.toLowerCase())
);
return (
<div>
<input
value={query}
onChange={(e) => setQuery(e.target.value)}
/>
<ul>
{filteredItems.map(item => (
<li key={item.id}>{item.name}</li>
))}
</ul>
</div>
);
}Problem: Every keystroke triggers expensive filtering, blocking the input from updating smoothly.
What is Concurrent Rendering?
Concurrent rendering allows React to:
- Interrupt rendering - Pause expensive work
- Prioritize updates - Handle urgent updates first
- Keep UI responsive - Maintain interactivity during heavy work
useTransition: Marking Updates as Non-Urgent
useTransition marks state updates as non-urgent, allowing React to keep the UI responsive.
Basic Usage
import { useMemo, useState, useTransition } from "react";
// helper: create heavy list once
function createItems(count = 20000) {
const arr = new Array(count);
for (let i = 0; i < count; i++) {
arr[i] = `Item ${i} - ${Math.random().toString(36).slice(2, 7)}`;
}
return arr;
}
const HEAVY_LIST = createItems(50000);
function HeavyList({ query }) {
const filtered = useMemo(() => {
// expensive CPU-bound task simulation
const res = HEAVY_LIST.filter((item) =>
item.toLowerCase().includes(query.toLowerCase())
);
console.log("Filtered count:", res.length);
return res.slice(0, 200); // render top 200 for demo
}, [query]);
return (
<div
style={{
maxHeight: 300,
overflow: "auto",
border: "1px solid #ddd",
}}
>
{filtered.map((item, i) => (
<div
key={i}
style={{ padding: 8, borderBottom: "1px solid #eee" }}
>
{item}
</div>
))}
</div>
);
}
export default function TransitionDemo() {
const [text, setText] = useState("");
const [query, setQuery] = useState("");
const [isPending, startTransition] = useTransition();
function handleChange(e) {
const val = e.target.value;
setText(val); // immediate update — keeps input responsive
// Mark heavy update as non-urgent:
startTransition(() => {
setQuery(val); // updating query triggers expensive filtering + renders
});
}
return (
<div style={{ padding: 20, fontFamily: "system-ui, sans-serif" }}>
<h2>useTransition Demo (Non-blocking updates)</h2>
<div style={{ marginBottom: 10 }}>
<input
className="border p-1 rounded"
value={text}
onChange={handleChange}
placeholder="Type to filter (feel the responsiveness)..."
style={{ width: 400, padding: 8 }}
/>
<span style={{ marginLeft: 12 }}>
{isPending ? "Updating results…" : "Idle"}
</span>
</div>
<HeavyList query={query} />
</div>
);
}useDeferredValue: Deferring Values
useDeferredValue defers a value update, similar to debouncing but integrated with React's rendering.
Basic Usage
import { useDeferredValue, useMemo, useState } from "react";
function createLargeList(count = 20000) {
const arr = new Array(count);
for (let i = 0; i < count; i++) {
arr[i] = `Product ${i} - ${Math.random().toString(36).slice(2, 6)}`;
}
return arr;
}
const PRODUCTS = createLargeList(20000);
function ProductsList({ filter }) {
const filtered = useMemo(() => {
console.log("Products filtering for:", filter);
return PRODUCTS.filter((p) =>
p.toLowerCase().includes(filter.toLowerCase())
).slice(0, 200);
}, [filter]);
return (
<div
style={{
maxHeight: 300,
overflow: "auto",
border: "1px solid #ddd",
}}
>
{filtered.map((p, i) => (
<div
key={i}
style={{ padding: 8, borderBottom: "1px solid #eee" }}
>
{p}
</div>
))}
</div>
);
}
export default function DeferredDemo() {
const [text, setText] = useState("");
// deferredText will update less aggressively than text
const deferredText = useDeferredValue(text);
return (
<div style={{ padding: 20, fontFamily: "system-ui, sans-serif" }}>
<h2>useDeferredValue Demo (Deferred heavy updates)</h2>
<div style={{ marginBottom: 10 }}>
<input
className="border p-1 rounded"
value={text}
onChange={(e) => setText(e.target.value)}
placeholder="Type to filter (UI remains instant because heavy filtering is deferred)"
style={{ width: 400, padding: 8 }}
/>
<span style={{ marginLeft: 12 }}>
{text === deferredText
? "Results up-to-date"
: "Results are deferred"}
</span>
</div>
<ProductsList filter={deferredText} />
</div>
);
}useTransition vs useDeferredValue
| Aspect | useTransition | useDeferredValue |
|---|---|---|
| Controls | State update function | Value itself |
| Use case | Marking updates as non-urgent | Deferring a value |
| Returns | [isPending, startTransition] | Deferred value |
| When to use | Controlling state updates | Deferring prop/state values |
useTransition:
- Controlling when state updates happen
- You want
isPendingstate - Multiple state updates to defer
useDeferredValue:
- Deferring a single value
- Simpler API
- Derived values from props/state
Best Practices
- Use for expensive operations - Filtering, sorting, transformations
- Keep urgent updates separate - Input updates should be immediate
- Show loading states - Use
isPendingto indicate work in progress - Combine with memoization - Use
useMemofor expensive computations - Test on slow devices - Verify improvements on low-end devices
Using Keys Correctly
Keys are a fundamental part of React's reconciliation algorithm. They help React identify which items have changed, been added, or removed. Using keys incorrectly can cause performance issues, bugs, and unnecessary DOM recreation.
What are Keys?
Keys are special string attributes you include when creating lists of elements. They help React identify which items have changed:
const items = [
{ id: 1, name: 'Apple' },
{ id: 2, name: 'Banana' },
{ id: 3, name: 'Cherry' },
];
function FruitList() {
return (
<ul>
{items.map(item => (
<li key={item.id}>{item.name}</li>
))}
</ul>
);
}The Problem: Using Index as Key
A common mistake is using array index as the key:
// ❌ Bad: Using index as key
function UserList({ users }) {
return (
<ul>
{users.map((user, index) => (
<li key={index}>
{user.name}
</li>
))}
</ul>
);
}Why Index Keys Are Problematic
- Breaks memoization - React can't identify which items actually changed
- Causes unnecessary DOM recreation - Items are destroyed and recreated
- State bugs - Component state can be associated with wrong items
- Performance issues - Slower reconciliation
Solution: Use Stable, Unique Keys
Always use stable, unique identifiers from your data:
// ✅ Good: Using unique ID
function UserList({ users }) {
return (
<ul>
{users.map(user => (
<li key={user.id}>
{user.name}
</li>
))}
</ul>
);
}What Makes a Good Key?
- Unique - Each key should be unique within the list
- Stable - Key should not change between renders
- Predictable - Key should be consistent for the same item
Examples of Good Keys
// ✅ Using ID from database
{users.map(user => <UserCard key={user.id} user={user} />)}
// ✅ Using combination of stable values
{items.map(item => (
<Item key={`${item.category}-${item.id}`} item={item} />
))}
// ✅ Using unique property
{products.map(product => (
<Product key={product.sku} product={product} />
))}Keys and Memoization
Keys are crucial for memoization to work correctly:
import { memo } from 'react';
const MemoizedUserCard = memo(function UserCard({ user }) {
return <div>{user.name}</div>;
});
function UserList({ users }) {
return (
<div>
{users.map(user => (
<MemoizedUserCard key={user.id} user={user} />
))}
</div>
);
}With stable keys:
- React correctly identifies unchanged items
- Memoized components don't rerender unnecessarily
- Performance is optimal
With index keys:
- React thinks all items changed when list reorders
- Memoization breaks
- All components rerender
When Index Keys Are Acceptable
Index keys are acceptable only when:
- List is static - Items never reorder, add, or remove
- No component state - Items don't have internal state
- No memoization - Components aren't memoized
- Simple rendering - No complex logic or side effects
However: Even in these cases, using stable IDs is better practice.
Best Practices
- Always use stable IDs - Prefer IDs from your data
- Never use index - Unless list is truly static
- Make keys unique - Each key should be unique in the list
- Keep keys stable - Don't change keys between renders
- Test with operations - Add, remove, reorder items
- Combine with memoization - Keys enable effective memoization
Performance Checklist
Use this checklist to audit your React application's performance. Go through each item and verify your implementation.
| Category | Item | Details | Priority |
|---|---|---|---|
| Profiling & Measurement | Profile with React DevTools | Identify actual performance bottlenecks before optimizing. Use React DevTools Profiler to find slow components and unnecessary rerenders. | High |
| Measure bundle size | Use tools like webpack-bundle-analyzer or source-map-explorer to identify large dependencies and optimize bundle size. | High | |
| Monitor Core Web Vitals | Track LCP (Largest Contentful Paint), FID (First Input Delay), and CLS (Cumulative Layout Shift) metrics to measure user experience. | High | |
| Test on slow devices | Verify performance on low-end devices and slow networks (3G throttling) to ensure good experience for all users. | Medium | |
| Use Performance API | Measure render times and component performance using browser Performance API for detailed metrics. | Medium | |
| Set performance budgets | Define acceptable limits for bundle size and load times. Fail builds if budgets are exceeded. | Medium | |
| Rerendering Optimization | Identify unnecessary rerenders | Use React DevTools Profiler to find components rerendering too often. Look for components that rerender when their props haven't changed. | High |
| Check parent-child relationships | Ensure child components only rerender when their props actually change. Use React.memo to prevent unnecessary rerenders. | High | |
| Verify Strict Mode behavior | Remember double renders in development are normal due to React Strict Mode. This doesn't occur in production. | Low | |
| Monitor state updates | Ensure state changes are necessary and minimal. Batch related state updates when possible. | High | |
| Memoization | Use React.memo strategically | Apply to components that: receive props that don't change often, are expensive to render, or are in lists with stable props. | High |
| Memoize functions with useCallback | Use when: passing functions to memoized components, functions are dependencies in other hooks, or function identity matters for child components. | High | |
| Memoize values with useMemo | Use for: expensive calculations (sorting, filtering, transformations), creating objects/arrays used as dependencies, or expensive derived values from props/state. | High | |
| Avoid over-memoization | Don't memoize simple primitives or cheap operations. Memoization has overhead—only use when beneficial. | Medium | |
| Verify dependency arrays | Ensure all dependencies are included correctly in dependency arrays. Missing dependencies cause stale closures and bugs. | High | |
| State Management | Eliminate derived state | Compute values directly instead of storing in state. Derived state causes unnecessary rerenders and synchronization issues. | High |
| Use useMemo for expensive derived values | Only when computation is actually expensive. For simple calculations, compute directly in render. | Medium | |
| Avoid useEffect for derived state | Don't sync derived values with effects. This causes double renders and synchronization bugs. | High | |
| Minimize state updates | Batch related state updates when possible. Use functional updates for state that depends on previous state. | Medium | |
| Use functional updates | For state that depends on previous state: setCount(c => c + 1) instead of setCount(count + 1). | Medium | |
| Event Handling | Debounce search inputs | Delay API calls until user stops typing. Recommended delay: 300-600ms for search inputs. | High |
| Debounce form validation | Prevent validation on every keystroke. Recommended delay: 500-1000ms for form validation. | Medium | |
| Throttle scroll handlers | Limit scroll event processing. Recommended throttle: 100-250ms for scroll handlers. | High | |
| Throttle resize handlers | Control window resize updates. Recommended throttle: 100-250ms for resize handlers. | Medium | |
| Remove event listeners | Clean up event listeners in useEffect cleanup function to prevent memory leaks. | High | |
| Code Splitting & Lazy Loading | Lazy load routes | Split by route for maximum impact. Each route loads only when navigated to, reducing initial bundle size. | High |
| Lazy load heavy components | Components with large dependencies should be lazy loaded. Use React.lazy() and Suspense. | High | |
| Lazy load modals/dialogs | Load only when opened. Modals are perfect candidates for lazy loading. | Medium | |
| Provide good fallbacks | User-friendly loading states for Suspense. Show skeleton loaders or spinners, not blank screens. | Medium | |
| Monitor bundle size | Track initial bundle and chunk sizes. Use bundle analyzers to identify optimization opportunities. | High | |
| Preload on interaction | Preload lazy components on hover/focus to improve perceived performance. | Low | |
| Component Architecture | Isolate components | Use React.memo to create render boundaries. Prevent cascading rerenders in complex component trees. | High |
| Split large components | Break down complex components into smaller ones. Smaller components are easier to optimize and test. | Medium | |
| Minimize prop drilling | Use Context or state management for deep props. Avoid passing props through many component levels. | Medium | |
| Optimize component tree | Keep component hierarchy shallow when possible. Deep trees can cause performance issues. | Low | |
| Context Optimization | Split contexts by concern | Separate contexts for different data types. Don't put everything in one context—it causes unnecessary rerenders. | High |
| Memoize context values | Use useMemo for context value objects. Prevents creating new objects on every render. | High | |
| Scope contexts appropriately | Only wrap components that need context. Don't wrap the entire app if context is only needed in part of the tree. | High | |
| Separate state/setters | Split state and dispatch into separate contexts when beneficial. Components that only need setters won't rerender on state changes. | Medium | |
| Avoid single large context | Don't put everything in one context. Large contexts cause all consumers to rerender when any value changes. | High | |
| List Rendering | Use stable, unique keys | Never use array index as key (unless list is truly static). Use IDs from your data or generate stable keys. | High |
| Virtualize large lists | Use react-window for lists with 100+ items. Virtualization dramatically improves performance for long lists. | High | |
| Memoize list items | Use React.memo for list item components. Prevents rerendering items that haven't changed. | High | |
| Optimize list item rendering | Keep list items lightweight. Complex list items can cause performance issues even with virtualization. | Medium | |
| Handle dynamic list heights | Use VariableSizeList when needed. FixedSizeList is faster but requires consistent item heights. | Medium | |
| Concurrent Features | Use useTransition | For non-urgent state updates that can be deferred. Keeps UI responsive during expensive updates. | Medium |
| Use useDeferredValue | For deferring expensive computations. Similar to debouncing but integrated with React's rendering. | Medium | |
| Show loading states | Use isPending from useTransition to indicate work in progress. Improves user experience. | Medium | |
| Keep urgent updates separate | Input updates should be immediate. Use startTransition only for non-urgent updates. | High | |
| Build & Runtime | Enable React Compiler | If using React 19+, enable automatic optimizations. React Compiler handles many memoization cases automatically. | Medium |
| Optimize images | Use Next.js Image component or similar optimizations. Images are often the largest assets. | High | |
| Enable production mode | Ensure production builds are optimized. Development mode includes extra checks that slow down performance. | High | |
| Minimize re-renders in production | Verify optimizations work in production builds. Some optimizations behave differently in production. | High | |
| Use production React | Ensure you're using the production build. Development React includes warnings and slower code paths. | High | |
| Testing & Validation | Test with real data | Use realistic data volumes in development. Testing with small datasets can hide performance issues. | Medium |
| Test list operations | Add, remove, reorder items to verify key usage. Ensure keys work correctly with list mutations. | Medium | |
| Verify memoization | Confirm memoized components don't rerender unnecessarily. Use React DevTools to verify memoization works. | High | |
| Test on slow networks | Verify lazy loading works correctly. Test with network throttling to ensure good loading experience. | Medium | |
| Profile before and after | Measure performance improvements. Use React DevTools Profiler to quantify optimization impact. | High | |
| Code Quality | Follow React best practices | Adhere to React's recommended patterns. Use official React documentation as reference. | High |
| Keep components simple | Simpler components are easier to optimize. Complex components are harder to memoize and optimize. | Medium | |
| Document performance decisions | Comment why optimizations were added. Helps future developers understand performance-critical code. | Low | |
| Review optimization impact | Verify optimizations actually improve performance. Not all optimizations provide measurable benefits. | High | |
| Avoid premature optimization | Only optimize when you've identified issues. Profile first, then optimize based on actual bottlenecks. | High | |
| React Compiler (React 19+) | Consider React Compiler | Evaluate if automatic optimizations fit your needs. React Compiler can reduce manual optimization work. | Low |
| Configure correctly | Set up React Compiler for your build tool (Vite/Next.js). Follow official setup instructions. | Medium | |
| Test compiler output | Verify compiler optimizations work as expected. Some edge cases may require manual optimization. | Medium | |
| Opt out when needed | Use "use no memo" directive for special cases. Some components may need to opt out of automatic optimization. | Low |
Conclusion
React performance optimization is about understanding how React works and applying the right techniques at the right time. This comprehensive guide covered:
- Understanding Rerendering - Foundation of all optimizations
- Memoization -
React.memo,useCallback,useMemo - Derived State - Avoiding unnecessary state
- Debouncing - Optimizing user input
- Throttling - Controlling frequent events
- React Compiler - Automatic optimization in React 19
- Code Splitting - Reducing bundle size
- Component Isolation - Creating render boundaries
- Context Optimization - Preventing context rerenders
- Virtualization - Rendering large lists efficiently
- Concurrent Rendering - Keeping UI responsive
- Keys - Proper list rendering
Final Best Practices
- Profile first - Use React DevTools to identify actual issues
- Optimize strategically - Don't optimize prematurely
- Measure impact - Verify optimizations improve performance
- Follow patterns - Use established patterns and best practices
- Keep learning - React performance optimization is an ongoing journey
Remember: Don't try to optimize performance of something which is already working great. Performance optimization is required only when you've found actual performance issues through feedback, profiling, or user complaints. Apply the techniques from this guide strategically, and your React applications will be fast, responsive, and performant.
