React Performance Optimization: Hooks, Memoization, and Best Practices
Performance is crucial for user experience. A slow React application can frustrate users and hurt your business. In this comprehensive guide, we'll explore proven techniques to optimize your React applications.
Why Performance Matters
- ⚡ User Experience: Faster apps lead to happier users
- 📱 Mobile Performance: Critical for users on slower devices
- 💰 Conversion Rates: Speed directly impacts business metrics
- 🔍 SEO: Google considers page speed in rankings
Understanding React Rendering
Before optimizing, understand how React renders:
- State/Props Change → Triggers re-render
- Component Re-renders → All child components re-render
- Virtual DOM Diff → React calculates changes
- DOM Update → Only changed elements update
React.memo: Prevent Unnecessary Re-renders
React.memo is a higher-order component that memoizes your component:
import React from 'react';
// Without memo - re-renders on every parent render
function ExpensiveComponent({ data }) {
console.log('Rendering ExpensiveComponent');
return <div>{data.map(item => <div key={item.id}>{item.name}</div>)}</div>;
}
// With memo - only re-renders when props change
const MemoizedComponent = React.memo(ExpensiveComponent);
// Custom comparison function
const MemoizedWithCustom = React.memo(
ExpensiveComponent,
(prevProps, nextProps) => {
return prevProps.data.length === nextProps.data.length;
}
);
When to Use React.memo
✅ Use when:
- Component renders often with same props
- Component is expensive to render
- Component receives complex props
❌ Don't use when:
- Props change frequently
- Component is simple/cheap to render
- Premature optimization
useMemo: Memoize Expensive Calculations
useMemo caches the result of expensive computations:
import { useMemo } from 'react';
function DataTable({ data, filter }) {
// ❌ Bad - recalculates on every render
const filteredData = data.filter(item => item.category === filter);
// ✅ Good - only recalculates when dependencies change
const filteredData = useMemo(() => {
console.log('Filtering data...');
return data.filter(item => item.category === filter);
}, [data, filter]);
return (
<table>
{filteredData.map(item => (
<tr key={item.id}>
<td>{item.name}</td>
</tr>
))}
</table>
);
}
Real-World Example
function ProductList({ products, searchTerm, sortBy }) {
const processedProducts = useMemo(() => {
// Expensive operations
let result = products.filter(p =>
p.name.toLowerCase().includes(searchTerm.toLowerCase())
);
result.sort((a, b) => {
if (sortBy === 'price') return a.price - b.price;
if (sortBy === 'name') return a.name.localeCompare(b.name);
return 0;
});
return result;
}, [products, searchTerm, sortBy]);
return (
<div>
{processedProducts.map(product => (
<ProductCard key={product.id} product={product} />
))}
</div>
);
}
useCallback: Memoize Functions
useCallback prevents function recreation on every render:
import { useCallback, useState } from 'react';
function ParentComponent() {
const [count, setCount] = useState(0);
const [items, setItems] = useState([]);
// ❌ Bad - new function on every render
const handleClick = () => {
console.log('Clicked');
};
// ✅ Good - same function reference
const handleClick = useCallback(() => {
console.log('Clicked');
}, []);
// With dependencies
const addItem = useCallback((item) => {
setItems(prev => [...prev, item]);
}, []);
return (
<div>
<button onClick={() => setCount(count + 1)}>Count: {count}</button>
<MemoizedChild onClick={handleClick} />
</div>
);
}
const MemoizedChild = React.memo(({ onClick }) => {
console.log('Child rendered');
return <button onClick={onClick}>Click Me</button>;
});
useCallback vs useMemo
// useCallback - memoizes the function itself
const memoizedCallback = useCallback(() => {
doSomething(a, b);
}, [a, b]);
// useMemo - memoizes the return value
const memoizedValue = useMemo(() => {
return computeExpensiveValue(a, b);
}, [a, b]);
// These are equivalent:
const memoizedCallback = useCallback(fn, deps);
const memoizedCallback = useMemo(() => fn, deps);
Code Splitting with React.lazy
Split your code to load only what's needed:
import React, { Suspense, lazy } from 'react';
// ❌ Bad - loads everything upfront
import HeavyComponent from './HeavyComponent';
import Dashboard from './Dashboard';
import Settings from './Settings';
// ✅ Good - loads on demand
const HeavyComponent = lazy(() => import('./HeavyComponent'));
const Dashboard = lazy(() => import('./Dashboard'));
const Settings = lazy(() => import('./Settings'));
function App() {
return (
<Suspense fallback={<div>Loading...</div>}>
<Routes>
<Route path="/dashboard" element={<Dashboard />} />
<Route path="/settings" element={<Settings />} />
</Routes>
</Suspense>
);
}
Route-based Code Splitting
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={<LoadingSpinner />}>
<Routes>
<Route path="/" element={<Home />} />
<Route path="/about" element={<About />} />
<Route path="/contact" element={<Contact />} />
</Routes>
</Suspense>
</BrowserRouter>
);
}
Virtualization for Long Lists
Use virtualization for rendering large lists:
import { FixedSizeList } from 'react-window';
function VirtualizedList({ items }) {
const Row = ({ index, style }) => (
<div style={style}>
{items[index].name}
</div>
);
return (
<FixedSizeList
height={600}
itemCount={items.length}
itemSize={50}
width="100%"
>
{Row}
</FixedSizeList>
);
}
Debouncing and Throttling
Control how often functions execute:
import { useState, useCallback } from 'react';
import { debounce } from 'lodash';
function SearchComponent() {
const [results, setResults] = useState([]);
// Debounce search - waits for user to stop typing
const handleSearch = useCallback(
debounce(async (query) => {
const data = await fetch(`/api/search?q=${query}`);
setResults(await data.json());
}, 300),
[]
);
return (
<input
type="text"
onChange={(e) => handleSearch(e.target.value)}
placeholder="Search..."
/>
);
}
Image Optimization
// Use next/image for automatic optimization
import Image from 'next/image';
function ProductImage({ src, alt }) {
return (
<Image
src={src}
alt={alt}
width={500}
height={500}
loading="lazy"
placeholder="blur"
/>
);
}
// Or use native lazy loading
function LazyImage({ src, alt }) {
return <img src={src} alt={alt} loading="lazy" />;
}
Profiling Your Application
Use React DevTools Profiler to identify bottlenecks:
import { Profiler } from 'react';
function onRenderCallback(
id,
phase,
actualDuration,
baseDuration,
startTime,
commitTime
) {
console.log(`${id} took ${actualDuration}ms to render`);
}
function App() {
return (
<Profiler id="App" onRender={onRenderCallback}>
<YourComponent />
</Profiler>
);
}
Best Practices Checklist
State Management
- ✅ Keep state as local as possible
- ✅ Use Context API wisely (can cause re-renders)
- ✅ Consider state management libraries for complex apps
Component Design
- ✅ Break down large components
- ✅ Use composition over inheritance
- ✅ Avoid inline object/array creation in props
Rendering
- ✅ Use keys properly in lists
- ✅ Avoid index as key for dynamic lists
- ✅ Implement error boundaries
Data Fetching
- ✅ Cache API responses
- ✅ Use SWR or React Query
- ✅ Implement pagination/infinite scroll
Common Anti-Patterns
1. Premature Optimization
// ❌ Don't optimize everything
const MemoizedEverything = React.memo(({ text }) => <div>{text}</div>);
// ✅ Only optimize when needed
function SimpleComponent({ text }) {
return <div>{text}</div>;
}
2. Incorrect Dependencies
// ❌ Missing dependencies
useEffect(() => {
fetchData(userId);
}, []); // userId should be in deps
// ✅ Correct dependencies
useEffect(() => {
fetchData(userId);
}, [userId]);
3. Creating Objects in Render
// ❌ New object on every render
<Component style={{ margin: 10 }} />
// ✅ Define outside or use useMemo
const style = { margin: 10 };
<Component style={style} />
Performance Monitoring
// Custom hook for performance monitoring
function usePerformance(componentName) {
useEffect(() => {
const start = performance.now();
return () => {
const end = performance.now();
console.log(`${componentName} took ${end - start}ms`);
};
});
}
function MyComponent() {
usePerformance('MyComponent');
// component logic
}
Conclusion
React performance optimization is about finding the right balance. Not every component needs memoization, and premature optimization can make code harder to maintain.
Key Takeaways:
- Profile first, optimize second
- Use React.memo for expensive components
- Leverage useMemo and useCallback wisely
- Implement code splitting for large apps
- Virtualize long lists
- Monitor and measure performance
Start with these techniques and measure the impact. Your users will thank you!
Resources
Enjoyed this article?
Explore more deep dives into architecture, performance, and modern .NET.