React is fast by default, but as your application grows, performance bottlenecks can emerge. Understanding React's rendering behavior and applying optimization techniques strategically can dramatically improve your application's performance. This guide covers practical techniques that make a real difference in production applications.
Understanding React Rendering
Before optimizing, you need to understand when and why React re-renders components:
- State changes in the component
- Props changes from parent
- Parent component re-renders (by default, all children re-render)
- Context value changes
The Golden Rule
Don't optimize prematurely. Profile first, identify real bottlenecks, then optimize. Most React apps don't need heavy optimization until they scale significantly.
React.memo for Component Memoization
React.memo prevents unnecessary re-renders by memoizing component output based on props.
// Without memoization - re-renders on every parent render
function ExpensiveComponent({ data, onClick }) {
console.log('Rendering ExpensiveComponent');
return <div onClick={onClick}>{data.map(item => item.name)}</div>;
}
// With memoization - only re-renders when props change
const ExpensiveComponent = React.memo(({ data, onClick }) => {
console.log('Rendering ExpensiveComponent');
return <div onClick={onClick}>{data.map(item => item.name)}</div>;
});
// Custom comparison function
const ExpensiveComponent = React.memo(
({ data, onClick }) => {
return <div onClick={onClick}>{data.map(item => item.name)}</div>;
},
(prevProps, nextProps) => {
// Return true if props are equal (skip render)
return prevProps.data.length === nextProps.data.length;
}
);
When to Use React.memo
- Component renders often with same props
- Component has expensive rendering logic
- Component receives large data structures as props
- Pure presentational components
When NOT to Use React.memo
- Props change frequently anyway
- Component is already fast
- Component renders rarely
- Props contain functions or objects created inline
useMemo for Expensive Calculations
useMemo memoizes the result of expensive computations.
import { useMemo } from 'react';
function DataTable({ data, filterText }) {
// Without useMemo - recalculates on every render
const filteredData = data.filter(item =>
item.name.toLowerCase().includes(filterText.toLowerCase())
);
// With useMemo - only recalculates when dependencies change
const filteredData = useMemo(() => {
console.log('Filtering data...');
return data.filter(item =>
item.name.toLowerCase().includes(filterText.toLowerCase())
);
}, [data, filterText]); // Dependencies
return (
<div>
{filteredData.map(item => (
<div key={item.id}>{item.name}</div>
))}
</div>
);
}
// More complex example
function Dashboard({ users, startDate, endDate }) {
const statistics = useMemo(() => {
console.log('Calculating statistics...');
const filtered = users.filter(user =>
user.createdAt >= startDate && user.createdAt <= endDate
);
return {
total: filtered.length,
active: filtered.filter(u => u.active).length,
revenue: filtered.reduce((sum, u) => sum + u.revenue, 0),
averageAge: filtered.reduce((sum, u) => sum + u.age, 0) / filtered.length,
};
}, [users, startDate, endDate]);
return <StatsDisplay stats={statistics} />;
}
useCallback for Function Memoization
useCallback memoizes function references to prevent unnecessary re-renders of child components.
import { useCallback, useState } from 'react';
function ParentComponent() {
const [count, setCount] = useState(0);
const [text, setText] = useState('');
// Without useCallback - new function on every render
// Child re-renders even when count doesn't change
const handleClick = () => {
setCount(count + 1);
};
// With useCallback - same function reference when deps don't change
const handleClick = useCallback(() => {
setCount(prev => prev + 1);
}, []); // No dependencies needed with updater function
const handleTextChange = useCallback((newText) => {
setText(newText);
}, []);
return (
<div>
<input value={text} onChange={(e) => handleTextChange(e.target.value)} />
{/* MemoizedChild only re-renders when handleClick reference changes */}
<MemoizedChild onClick={handleClick} />
</div>
);
}
const MemoizedChild = React.memo(({ onClick }) => {
console.log('Child rendered');
return <button onClick={onClick}>Click me</button>;
});
useCallback vs useMemo
useCallback(fn, deps) is equivalent to useMemo(() => fn, deps).
Use useCallback for functions, useMemo for values.
Code Splitting and Lazy Loading
Split your code into smaller chunks and load them on demand to reduce initial bundle size.
Route-Based Code Splitting
import { lazy, Suspense } from 'react';
import { BrowserRouter, Routes, Route } from 'react-router-dom';
// Lazy load route components
const Home = lazy(() => import('./pages/Home'));
const Dashboard = lazy(() => import('./pages/Dashboard'));
const Profile = lazy(() => import('./pages/Profile'));
const Settings = lazy(() => import('./pages/Settings'));
function App() {
return (
<BrowserRouter>
<Suspense fallback={<div>Loading...</div>}>
<Routes>
<Route path="/" element={<Home />} />
<Route path="/dashboard" element={<Dashboard />} />
<Route path="/profile" element={<Profile />} />
<Route path="/settings" element={<Settings />} />
</Routes>
</Suspense>
</BrowserRouter>
);
}
Component-Based Code Splitting
import { lazy, Suspense, useState } from 'react';
// Lazy load heavy components
const HeavyChart = lazy(() => import('./components/HeavyChart'));
const HeavyDataTable = lazy(() => import('./components/HeavyDataTable'));
function Dashboard() {
const [showChart, setShowChart] = useState(false);
return (
<div>
<h1>Dashboard</h1>
<button onClick={() => setShowChart(true)}>
Show Chart
</button>
{showChart && (
<Suspense fallback={<div>Loading chart...</div>}>
<HeavyChart />
</Suspense>
)}
</div>
);
}
Preloading Components
// Preload on hover for better UX
const HeavyComponent = lazy(() => import('./HeavyComponent'));
function Navigation() {
const handleMouseEnter = () => {
// Preload before user clicks
import('./HeavyComponent');
};
return (
<button
onMouseEnter={handleMouseEnter}
onClick={() => navigate('/heavy')}
>
Go to Heavy Page
</button>
);
}
Virtual Scrolling for Large Lists
Rendering thousands of items is slow. Virtual scrolling only renders visible items.
// Using react-window
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>
);
}
// Using react-virtuoso (more flexible)
import { Virtuoso } from 'react-virtuoso';
function VirtualizedList({ items }) {
return (
<Virtuoso
style={{ height: '600px' }}
data={items}
itemContent={(index, item) => (
<div>
<h3>{item.name}</h3>
<p>{item.description}</p>
</div>
)}
/>
);
}
Debouncing and Throttling
import { useState, useCallback } from 'react';
import { debounce } from 'lodash';
function SearchComponent() {
const [results, setResults] = useState([]);
// Debounce search - only call after user stops typing
const debouncedSearch = useCallback(
debounce(async (query) => {
const results = await fetchSearchResults(query);
setResults(results);
}, 300), // 300ms delay
[]
);
const handleChange = (e) => {
debouncedSearch(e.target.value);
};
return (
<div>
<input onChange={handleChange} placeholder="Search..." />
<SearchResults results={results} />
</div>
);
}
// Throttle scroll events
import { throttle } from 'lodash';
function InfiniteScroll() {
const handleScroll = useCallback(
throttle(() => {
const scrollPercentage =
(window.scrollY / (document.body.scrollHeight - window.innerHeight)) * 100;
if (scrollPercentage > 80) {
loadMoreData();
}
}, 200), // Max once per 200ms
[]
);
useEffect(() => {
window.addEventListener('scroll', handleScroll);
return () => window.removeEventListener('scroll', handleScroll);
}, [handleScroll]);
return <div>{/* content */}</div>;
}
Optimizing Context
Context changes cause all consumers to re-render. Split contexts and memoize values.
// Bad - Single context with multiple values
const AppContext = createContext();
function AppProvider({ children }) {
const [user, setUser] = useState(null);
const [theme, setTheme] = useState('light');
const [settings, setSettings] = useState({});
// All consumers re-render when ANY value changes
return (
<AppContext.Provider value={{ user, setUser, theme, setTheme, settings, setSettings }}>
{children}
</AppContext.Provider>
);
}
// Good - Split contexts by concern
const UserContext = createContext();
const ThemeContext = createContext();
const SettingsContext = createContext();
// Memoize context values
function UserProvider({ children }) {
const [user, setUser] = useState(null);
const value = useMemo(() => ({ user, setUser }), [user]);
return (
<UserContext.Provider value={value}>
{children}
</UserContext.Provider>
);
}
Image Optimization
// Lazy load images
function LazyImage({ src, alt }) {
return (
<img
src={src}
alt={alt}
loading="lazy"
decoding="async"
/>
);
}
// Use Next.js Image component (automatic optimization)
import Image from 'next/image';
function OptimizedImage() {
return (
<Image
src="/photo.jpg"
alt="Photo"
width={800}
height={600}
placeholder="blur"
blurDataURL="data:image/jpeg;base64,..."
/>
);
}
// Responsive images
function ResponsiveImage({ src, alt }) {
return (
<picture>
<source media="(min-width: 1200px)" srcSet={`${src}?w=1200`} />
<source media="(min-width: 768px)" srcSet={`${src}?w=768`} />
<img src={`${src}?w=400`} alt={alt} loading="lazy" />
</picture>
);
}
Performance Measurement
React DevTools Profiler
Use the Profiler tab in React DevTools to identify slow components and unnecessary re-renders.
Performance API
import { Profiler } from 'react';
function onRenderCallback(
id, // Component ID
phase, // "mount" or "update"
actualDuration, // Time spent rendering
baseDuration, // Estimated time without memoization
startTime,
commitTime,
interactions
) {
console.log(`${id} (${phase}) took ${actualDuration}ms`);
}
function App() {
return (
<Profiler id="Dashboard" onRender={onRenderCallback}>
<Dashboard />
</Profiler>
);
}
Web Vitals
// Install web-vitals
npm install web-vitals
// Measure Core Web Vitals
import { getCLS, getFID, getFCP, getLCP, getTTFB } from 'web-vitals';
function sendToAnalytics(metric) {
// Send to your analytics endpoint
console.log(metric);
}
getCLS(sendToAnalytics);
getFID(sendToAnalytics);
getFCP(sendToAnalytics);
getLCP(sendToAnalytics);
getTTFB(sendToAnalytics);
Bundle Size Optimization
# Analyze bundle size
npm install -D webpack-bundle-analyzer
# Add to package.json
"analyze": "ANALYZE=true next build"
# Run analysis
npm run analyze
Tree Shaking
// Bad - imports entire library
import _ from 'lodash';
const result = _.debounce(fn, 300);
// Good - imports only what you need
import debounce from 'lodash/debounce';
const result = debounce(fn, 300);
// Even better - use modern alternatives
import { debounce } from 'lodash-es'; // ES modules for better tree shaking
Performance Checklist
- Profile before optimizing
- Use React.memo for expensive components
- Memoize expensive calculations with useMemo
- Memoize callbacks with useCallback
- Implement code splitting for routes
- Use virtual scrolling for large lists
- Debounce user inputs
- Optimize images (lazy loading, responsive)
- Split context by concern
- Minimize bundle size
- Monitor Core Web Vitals
Conclusion
React performance optimization is about making strategic decisions based on real data. Don't optimize everything—focus on bottlenecks that actually impact user experience. Profile your application, identify slow components, apply appropriate optimization techniques, and measure the results.
Remember: premature optimization is the root of all evil. Build first, measure second, optimize third. Your users will thank you for a fast, responsive application that doesn't sacrifice maintainability for marginal performance gains.
Is your React app slow?
Yonda Solutions can review a React application, find the bottlenecks and fix them. Request a quote or email info@yondasolutions.com.