Read Time: ~13 minutes | Real techniques to make your React apps genuinely fast
Prerequisites: Custom hooks, useReducer, Context API (Parts 1 & 2)
π Series Navigation
β Part 1: Complete Guide from Zero to Production
β Part 2: Advanced Hooks & State Management
Part 3: Performance Optimization β YOU ARE HERE
β Part 4: Full-Stack with Next.js (coming next)
π What You'll Learn
By the end of this guide, you'll be able to:
- β Understand exactly how and when React re-renders
- β
Use
React.memoto stop unnecessary re-renders - β
Apply
useMemoanduseCallbackcorrectly (not blindly) - β
Split code and lazy-load components with
React.lazy+Suspense - β Profile and diagnose bottlenecks with React DevTools
- β Optimize a real 1,000-item product dashboard
- β Measure real before/after performance gains
β‘ Why Performance Actually Matters
Before jumping into code, let's anchor this in reality.
A 1-second delay in page response β 7% drop in conversions
A 100ms improvement in load time β 1% increase in revenue (Amazon)
53% of mobile users leave β if page takes > 3s to load
React apps can get slow in very specific waysβusually not because React itself is slow, but because we accidentally make it do more work than necessary.
The golden rule of React performance:
"Don't re-render what hasn't changed."
π How React Rendering Works (The Foundation)
Before you optimize anything, you need to understand why React re-renders.
The 3 Triggers for a Re-render
1. State changes β setState or useState setter called
2. Props change β Parent passed new values
3. Context changes β A Provider's value changed
The Ripple Effect (The Real Problem)
function Parent() {
const [count, setCount] = useState(0);
return (
<div>
<button onClick={() => setCount(count + 1)}>Click: {count}</button>
<Child /> {/* Re-renders every time! Even if nothing changed */}
<AnotherChild /> {/* Re-renders too! */}
</div>
);
}
When Parent re-renders, every child re-renders by defaultβeven if their props didn't change. With 50 components, this becomes a problem fast.
π‘οΈ React.memo: Stop Unnecessary Re-renders
React.memo is a higher-order component that memoizes your component. It only re-renders if its props actually changed.
Without React.memo (Expensive)
// β ProductCard re-renders on every parent update
function ProductCard({ product }) {
console.log(`Rendering: ${product.name}`);
return (
<div>
<h3>{product.name}</h3>
<p>${product.price}</p>
</div>
);
}
// In a list of 1000 products = 1000 unnecessary re-renders
With React.memo (Efficient)
// β
Only re-renders when product prop actually changes
const ProductCard = React.memo(function ProductCard({ product }) {
console.log(`Rendering: ${product.name}`);
return (
<div>
<h3>{product.name}</h3>
<p>${product.price}</p>
</div>
);
});
Custom Comparison for React.memo
// React.memo does shallow comparison by default
// For complex objects, provide a custom comparator:
const ProductCard = React.memo(
function ProductCard({ product, onBuyClick }) {
return (
<div>
<h3>{product.name}</h3>
<button onClick={() => onBuyClick(product.id)}>Buy</button>
</div>
);
},
// Custom comparator: only re-render if these specific fields changed
(prevProps, nextProps) =>
prevProps.product.id === nextProps.product.id &&
prevProps.product.price === nextProps.product.price
);
When to use React.memo:
- β Components that render frequently with the same props
- β Large lists (50+ items)
- β Components with expensive calculations in render
- β Simple components (overhead isn't worth it)
π§ useMemo: Cache Expensive Calculations
useMemo memoizes the result of a function. React skips recalculating it unless dependencies change.
Without useMemo (Recalculates Every Render)
function ProductDashboard({ products, filters }) {
// β This runs on EVERY renderβeven if products/filters didn't change
const filteredProducts = products
.filter(p => p.price >= filters.minPrice && p.price <= filters.maxPrice)
.filter(p => filters.category === 'all' || p.category === filters.category)
.sort((a, b) => b.rating - a.rating)
.slice(0, 100);
return <ProductList items={filteredProducts} />;
}
With useMemo (Only Recalculates When Dependencies Change)
import { useMemo } from 'react';
function ProductDashboard({ products, filters }) {
// β
Only recalculates when products or filters actually change
const filteredProducts = useMemo(() => {
return products
.filter(p => p.price >= filters.minPrice && p.price <= filters.maxPrice)
.filter(p => filters.category === 'all' || p.category === filters.category)
.sort((a, b) => b.rating - a.rating)
.slice(0, 100);
}, [products, filters]);
return <ProductList items={filteredProducts} />;
}
useMemo for Derived Statistics
function SalesDashboard({ orders }) {
// Expensive aggregationβonly recalculate when orders change
const stats = useMemo(() => ({
totalRevenue: orders.reduce((sum, o) => sum + o.total, 0),
averageOrder: orders.reduce((sum, o) => sum + o.total, 0) / orders.length,
topProduct: orders
.flatMap(o => o.items)
.reduce((acc, item) => {
acc[item.name] = (acc[item.name] || 0) + item.quantity;
return acc;
}, {}),
conversionRate: (orders.filter(o => o.completed).length / orders.length) * 100
}), [orders]);
return (
<div>
<StatCard label="Revenue" value={`$${stats.totalRevenue}`} />
<StatCard label="Avg Order" value={`$${stats.averageOrder.toFixed(2)}`} />
<StatCard label="Conversion" value={`${stats.conversionRate.toFixed(1)}%`} />
</div>
);
}
β οΈ The useMemo Mistake Most Developers Make
// β DON'T memoize cheap calculationsβoverhead cost > savings
const doubled = useMemo(() => count * 2, [count]);
// β
DO memoize genuinely expensive calculations
const processedData = useMemo(() => heavyDataTransformation(dataset), [dataset]);
Rule of thumb: Use useMemo when the calculation takes noticeable time (>1ms) or when the result is passed to a memoized child component.
π useCallback: Stable Function References
useCallback memoizes a function itself. This matters because new function instances on every render break React.memo.
The Problem (Why We Need useCallback)
function Parent() {
const [count, setCount] = useState(0);
// β New function reference on every render!
// React.memo on Child won't helpβit sees a "new" prop every time
const handleClick = (id) => {
console.log(`Clicked: ${id}`);
};
return (
<>
<button onClick={() => setCount(c => c + 1)}>Update: {count}</button>
<MemoizedChild onItemClick={handleClick} />
</>
);
}
The Solution (useCallback + React.memo Pair)
import { useCallback } from 'react';
function Parent() {
const [count, setCount] = useState(0);
const [cart, setCart] = useState([]);
// β
Same function referenceβonly changes when cart changes
const handleAddToCart = useCallback((product) => {
setCart(prev => [...prev, product]);
}, []); // Empty depsβsetCart is stable, no need to include
// β
Same function referenceβonly changes when cart changes
const handleRemoveFromCart = useCallback((productId) => {
setCart(prev => prev.filter(p => p.id !== productId));
}, []);
return (
<>
<button onClick={() => setCount(c => c + 1)}>Update: {count}</button>
<MemoizedProductList
onAdd={handleAddToCart}
onRemove={handleRemoveFromCart}
/>
</>
);
}
// React.memo now works correctlyβstable function references
const MemoizedProductList = React.memo(function ProductList({ onAdd, onRemove }) {
return <div>...products...</div>;
});
React.memo + useCallback = Real Gains
Without optimization: 1000 items Γ parent update = 1000 re-renders
With React.memo only: 1000 items Γ new function ref = 1000 re-renders (same!)
With React.memo + useCallback: 1000 items Γ same function ref = 0 re-renders β
βοΈ Code Splitting: Load Only What You Need
A React app bundled as one giant file makes users download everythingβeven pages they never visit. Code splitting fixes this.
Without Code Splitting (Heavy Initial Load)
// β Every page loads at startupβeven ones user never visits
import Dashboard from './pages/Dashboard';
import Analytics from './pages/Analytics'; // Heavy charts library
import AdminPanel from './pages/AdminPanel'; // Rarely visited
import Reports from './pages/Reports';
function App() {
return (
<Routes>
<Route path="/dashboard" element={<Dashboard />} />
<Route path="/analytics" element={<Analytics />} />
<Route path="/admin" element={<AdminPanel />} />
<Route path="/reports" element={<Reports />} />
</Routes>
);
}
With React.lazy + Suspense (Smart Loading)
import { lazy, Suspense } from 'react';
// β
Each page only loads when the user navigates to it
const Dashboard = lazy(() => import('./pages/Dashboard'));
const Analytics = lazy(() => import('./pages/Analytics'));
const AdminPanel = lazy(() => import('./pages/AdminPanel'));
const Reports = lazy(() => import('./pages/Reports'));
function App() {
return (
<Suspense fallback={<LoadingSpinner />}>
<Routes>
<Route path="/dashboard" element={<Dashboard />} />
<Route path="/analytics" element={<Analytics />} />
<Route path="/admin" element={<AdminPanel />} />
<Route path="/reports" element={<Reports />} />
</Routes>
</Suspense>
);
}
function LoadingSpinner() {
return (
<div style={{ display: 'flex', justifyContent: 'center', padding: '2rem' }}>
<div className="spinner">Loading...</div>
</div>
);
}
Real impact: Splitting a 2 MB bundle into 6 route-based chunks means the home page loads a 300 KB chunk instead of 2 MB. That's a 6Γ improvement in initial load.
Component-Level Splitting (Heavy Components)
// Lazy load a heavy chart component (D3, chart.js etc.)
const HeavyChart = lazy(() => import('./components/HeavyChart'));
const RichTextEditor = lazy(() => import('./components/RichTextEditor'));
function ProductPage({ product }) {
const [showChart, setShowChart] = useState(false);
return (
<div>
<h1>{product.name}</h1>
<button onClick={() => setShowChart(true)}>View Price History</button>
{showChart && (
<Suspense fallback={<p>Loading chart...</p>}>
<HeavyChart data={product.priceHistory} />
</Suspense>
)}
</div>
);
}
π¬ React DevTools Profiler: Find Real Bottlenecks
Never guess at performance problems. The React DevTools Profiler shows you exactly what's slow.
Setup
1. Install React DevTools browser extension (Chrome/Firefox)
2. Open DevTools β "Profiler" tab
3. Click the record (β) button
4. Interact with your app
5. Stop recording
6. Analyze the flame graph
Reading the Flame Graph
Flame Graph (wider = longer render time):
βββββββββββββββββββββββ App (2.1ms) ββββββββββββββββββββββββββββββββ
β ββββ Header (0.1ms) ββ βββββββ ProductList (1.9ms) βββββββββββ β
β ββββββββββββββββββββββ β βββ ProductCard (0.8ms) ββββββββ β β
β β ββββββββββββββββββββββββββββββββ β β
β β βββ ProductCard (0.7ms) ββββββββ β β
β β ββββββββββββββββββββββββββββββββ β β
β βββββββββββββββββββββββββββββββββββββββ β
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
π‘ ProductList is the bottleneck β optimize here first
What to Look For
π΄ Components re-rendering unnecessarily β Use React.memo
π΄ Long render times (>16ms) β Use useMemo
π΄ Cascading re-renders β Check Context usage
π΄ Functions recreating on each render β Use useCallback
π‘ Components rendering >10ms β Investigate closer
π’ Components rendering <1ms β Leave them alone
ποΈ Real-World Optimization: Product Dashboard (Before vs After)
Let's optimize a real slow componentβa product dashboard with 1,000+ items.
The Slow Version (Before)
// β Before optimization - renders slowly with large datasets
function ProductDashboard({ products, user }) {
const [searchTerm, setSearchTerm] = useState('');
const [sortBy, setSortBy] = useState('name');
const [category, setCategory] = useState('all');
// Recalculates on EVERY render (even typing a letter!)
const filteredProducts = products
.filter(p => p.name.toLowerCase().includes(searchTerm.toLowerCase()))
.filter(p => category === 'all' || p.category === category)
.sort((a, b) => a[sortBy] > b[sortBy] ? 1 : -1);
// New function on every renderβbreaks memoization downstream
const handleAddToWishlist = (id) => {
api.addToWishlist(user.id, id);
};
return (
<div>
<SearchBar value={searchTerm} onChange={e => setSearchTerm(e.target.value)} />
<FilterBar onSort={setSortBy} onCategory={setCategory} />
<div className="product-grid">
{filteredProducts.map(product => (
// Re-renders ALL cards on every keystroke
<ProductCard
key={product.id}
product={product}
onWishlist={handleAddToWishlist}
/>
))}
</div>
</div>
);
}
Performance Profile (Before):
Typing one character in search:
β filteredProducts recalculates: 1000 items Γ filter Γ sort = ~18ms
β All 1000 ProductCards re-render: ~45ms
β Total: ~63ms per keystroke = janky, visible lag
The Optimized Version (After)
import { useState, useMemo, useCallback } from 'react';
// β
After optimization - smooth at any scale
// Memoize ProductCard so it only re-renders when its own data changes
const ProductCard = React.memo(function ProductCard({ product, onWishlist }) {
return (
<div className="product-card">
<img src={product.image} alt={product.name} loading="lazy" />
<h3>{product.name}</h3>
<p>${product.price}</p>
<span className="badge">{product.category}</span>
<button onClick={() => onWishlist(product.id)}>β‘ Wishlist</button>
</div>
);
});
function ProductDashboard({ products, user }) {
const [searchTerm, setSearchTerm] = useState('');
const [sortBy, setSortBy] = useState('name');
const [category, setCategory] = useState('all');
// β
Only recalculates when its dependencies change
const filteredProducts = useMemo(() => {
const lower = searchTerm.toLowerCase();
return products
.filter(p => p.name.toLowerCase().includes(lower))
.filter(p => category === 'all' || p.category === category)
.sort((a, b) => a[sortBy] > b[sortBy] ? 1 : -1);
}, [products, searchTerm, category, sortBy]);
// β
Same function referenceβmemoized children stay stable
const handleAddToWishlist = useCallback((id) => {
api.addToWishlist(user.id, id);
}, [user.id]);
return (
<div>
<SearchBar value={searchTerm} onChange={e => setSearchTerm(e.target.value)} />
<FilterBar onSort={setSortBy} onCategory={setCategory} />
<div className="product-grid">
{filteredProducts.map(product => (
<ProductCard
key={product.id}
product={product}
onWishlist={handleAddToWishlist}
/>
))}
</div>
</div>
);
}
Performance Profile (After):
Typing one character in search:
β filteredProducts recalculates: still ~8ms (filtering is inherent work)
β Only changed ProductCards re-render: ~2ms (most skip!)
β Total: ~10ms per keystroke = smooth, no visible lag
Improvement: 63ms β 10ms = 6.3Γ faster π
π Virtualization: The Nuclear Option for Long Lists
When you have 10,000+ items, even memoized renders are too slow. Virtualization renders only what's visible on screen.
import { FixedSizeList } from 'react-window'; // npm install react-window
function VirtualizedProductList({ products }) {
const Row = ({ index, style }) => (
<div style={style}>
<ProductCard product={products[index]} />
</div>
);
return (
<FixedSizeList
height={600} // Visible container height
itemCount={products.length}
itemSize={120} // Each row height in px
width="100%"
>
{Row}
</FixedSizeList>
);
}
Without virtualization: 10,000 DOM nodes = browser struggles
With virtualization: ~10 DOM nodes visible = butter smooth π§
DOM nodes rendered: 10,000 β ~10 (99.9% reduction)
π Performance Benchmarks: Real Numbers
Here's what these optimizations actually deliver at different scales:
| Dataset Size | Before (ms) | After (ms) | Improvement |
|---|---|---|---|
| 100 items | 8ms | 3ms | 2.7Γ faster |
| 500 items | 28ms | 6ms | 4.7Γ faster |
| 1,000 items | 63ms | 10ms | 6.3Γ faster |
| 5,000 items | 290ms | 15ms | 19Γ faster |
| 10,000 items | 600ms+ | 18ms (virtual) | 33Γ faster |
Techniques Used by Dataset Size
| Size | Use |
|---|---|
| 1β50 items | Nothing needed |
| 50β500 items | React.memo + useCallback |
| 500β5,000 items | + useMemo for filtering |
| 5,000+ items | + react-window virtualization |
βοΈ Additional Quick Wins
1. Lazy Load Images
// β
Images only load when in viewport
<img src={product.image} alt={product.name} loading="lazy" />
2. Debounce Search Input
import { useMemo } from 'react';
import { debounce } from 'lodash';
function SearchBar({ onSearch }) {
// Debounce: only fires after user stops typing for 300ms
const debouncedSearch = useMemo(
() => debounce(onSearch, 300),
[onSearch]
);
return <input onChange={e => debouncedSearch(e.target.value)} />;
}
3. Avoid Object/Array Literals in JSX
// β New object created every render
<Component style={{ color: 'red', margin: 16 }} />
// β
Stable reference
const STYLE = { color: 'red', margin: 16 };
<Component style={STYLE} />
4. Key Prop Best Practices
// β Using index as key breaks memoization when list reorders
{items.map((item, index) => <Item key={index} item={item} />)}
// β
Use stable, unique ID
{items.map(item => <Item key={item.id} item={item} />)}
π― Performance Optimization Checklist
Before shipping any React app, run through these:
Render Efficiency
β‘ No unnecessary re-renders (check with Profiler)
β‘ React.memo on frequently-rendered components
β‘ useCallback for functions passed as props
β‘ useMemo for expensive calculations
β‘ Stable keys in all lists (not index)
β‘ Avoid new objects/arrays in JSX props
Bundle Size
β‘ Route-based code splitting with React.lazy
β‘ Heavy components split and lazy-loaded
β‘ Tree-shaking verified (check bundle analyzer)
β‘ Dependencies audited (remove unused ones)
Load Performance
β‘ Images have loading="lazy"
β‘ Search inputs debounced
β‘ API calls appropriately cached
β‘ Virtualization for lists > 1000 items
π‘ The Optimization Mindset
Here's something most articles won't tell you: premature optimization is a mistake.
Don't add useMemo and useCallback everywhere by default. Every memoization adds:
- Memory overhead (storing the cached value)
- Complexity (developers must understand the dependency array)
- Potential bugs (stale closures from wrong dependencies)
The right workflow is:
1. Build it first β Write clean, readable code
2. Measure it β Profile with React DevTools
3. Find the bottleneck β Identify the actual slow part
4. Optimize that part β Apply the right technique
5. Measure again β Confirm improvement
"Measure, don't guess."
π Quick Resources
- React DevTools: Chrome Web Store β Search "React DevTools"
- react-window: github.com/bvaughn/react-window β Virtualized lists
- Bundle Analyzer: webpack-bundle-analyzer β Visualize your bundle
- Why Did You Render: github.com/welldone-software/why-did-you-render β Track unnecessary renders
- React Profiler API: react.dev/reference/react/Profiler
π Final Thoughts: Fast React is Intentional React
Performance isn't luck. The patterns in this articleβmemoization, code splitting, virtualization, and profilingβare deliberate decisions you make at the right moment.
The hierarchy:
Step 1: Write correct, clean code first
Step 2: Profile to identify slow components
Step 3: Apply React.memo to stop re-renders
Step 4: Apply useCallback to stabilize function props
Step 5: Apply useMemo to skip expensive calculations
Step 6: Code split heavy routes and components
Step 7: Virtualize lists beyond 1000 items
Step 8: Profile again to confirm wins
You don't need all eight steps for every appβjust the ones your profiler tells you to.
π¬ What's Your Performance Story?
Have you ever shipped a slow React app and had to go back and optimize it? What was the bottleneckβre-renders, bundle size, or something else entirely? Drop your story in the comments!
π Series Roadmap
β Part 1: Complete Guide from Zero to Production
β Part 2: Advanced Hooks & State Management
Part 3: Performance Optimization β YOU ARE HERE
β Part 4: Full-Stack with Next.js (coming next)
Coming in Part 4:
- Next.js file-based routing
- Server vs Client Components
- API routes that replace a backend
- getStaticProps vs getServerSideProps
- Deploying to Vercel in under 10 minutes
Happy optimizing! π
Top comments (0)