Building Performant React Apps
Practical techniques for optimizing React applications that scale to production.
Every millisecond counts in production applications. React’s declarative model makes writing components easy, but without attention to rendering behavior, apps become sluggish. Here’s how to keep them fast.
Understanding React’s Rendering Model
React’s virtual DOM diffing is efficient, but unnecessary re-renders can bottleneck your application. The goal isn’t to render less — it’s to render only what changed.
When Components Re-render
A component re-renders when:
- Its state changes
- Its props change
- Its parent re-renders
The key insight: a parent’s re-render doesn’t mean children need to re-render. It means they’re scheduled to.
// This causes unnecessary re-renders of Button
function Parent() {
const [count, setCount] = useState(0);
const [name, setName] = useState('');
return (
<div>
<input value={name} onChange={(e) => setName(e.target.value)} />
<button onClick={() => setCount(c => c + 1)}>Count: {count}</button>
{/* Title re-renders even though its props didn't change */}
<Title text="Hello" />
</div>
);
}
Memoization Strategies
React.memo for Component-Level Memoization
// Without memo: re-renders on every Parent render
function Title({ text }: { text: string }) {
return <h1>{text}</h1>;
}
// With memo: only re-renders when text actually changes
const Title = React.memo(function Title({ text }: { text: string }) {
return <h1>{text}</h1>;
});
useMemo and useCallback for Value Stability
function ProductList({ categoryId }: { categoryId: string }) {
// memoize expensive computation
const filteredProducts = useMemo(() => {
return products.filter(p => p.categoryId === categoryId);
}, [categoryId]);
// memoize callback to pass to child
const handleClick = useCallback((productId: string) => {
navigate(`/products/${productId}`);
}, []); // empty deps: function never changes
return (
<ul>
{filteredProducts.map(p => (
<ProductCard key={p.id} product={p} onClick={handleClick} />
))}
</ul>
);
}
Important: Don’t memoize everything. Memoization has a cost — comparison overhead. Profile first, optimize second.
Code Splitting
Route-Level Splitting
import { lazy, Suspense } from 'react';
import { Routes, Route } from 'react-router-dom';
const Dashboard = lazy(() => import('./pages/Dashboard'));
const Settings = lazy(() => import('./pages/Settings'));
const Analytics = lazy(() => import('./pages/Analytics'));
function App() {
return (
<Suspense fallback={<LoadingSpinner />}>
<Routes>
<Route path="/" element={<Dashboard />} />
<Route path="/settings" element={<Settings />} />
<Route path="/analytics" element={<Analytics />} />
</Routes>
</Suspense>
);
}
Component-Level Splitting
// Only load the heavy chart when user scrolls it into view
const RevenueChart = lazy(() => import('./charts/RevenueChart'));
function Dashboard() {
const [showChart, setShowChart] = useState(false);
return (
<div>
<SummaryCards />
{showChart && (
<Suspense fallback={<ChartSkeleton />}>
<RevenueChart />
</Suspense>
)}
<button onClick={() => setShowChart(true)}>Show Revenue Chart</button>
</div>
);
}
Vite Dynamic Imports
// Chunk non-critical features
const handleExport = async () => {
const { exportToPDF } = await import('./utils/exporter');
exportToPDF(data);
};
Measuring Performance
React DevTools Profiler
The Profiler identifies which components render too often and why. Look for:
- Components that render frequently without visible output change
- “Highlight updates” during interaction to see what re-renders
Web Vitals in React
import { useEffect } from 'react';
import { onLCP, onINP, onCLS } from 'web-vitals';
function App() {
useEffect(() => {
function sendToAnalytics({ name, value, id }: Metric) {
// Send to your analytics service
fetch('/api/vitals', {
method: 'POST',
body: JSON.stringify({ name, value, id }),
});
}
onLCP(sendToAnalytics);
onINP(sendToAnalytics);
onCLS(sendToAnalytics);
}, []);
return <Router />;
}
Performance Budgets in CI
// vitals.config.js
export default {
lcp: { warn: 2500, fail: 4000 },
inp: { warn: 200, fail: 500 },
cls: { warn: 0.1, fail: 0.25 },
};
State Architecture for Performance
Colocation Principle
Keep state as local as possible. Move state up the tree only when shared is needed.
// Bad: state too high, causes unnecessary re-renders
function App() {
const [searchQuery, setSearchQuery] = useState('');
return (
<div>
<Header />
<Search value={searchQuery} onChange={setSearchQuery} />
<Content searchQuery={searchQuery} /> {/* re-renders on every keystroke */}
</div>
);
}
// Good: state collocated with where it's used
function SearchPage() {
return (
<div>
<Header />
<SearchWithState />
<Content /> {/* only renders when its props change */}
</div>
);
}
Virtualization for Long Lists
import { useVirtualizer } from '@tanstack/react-virtual';
function VirtualList({ items }: { items: Item[] }) {
const parentRef = useRef<HTMLDivElement>(null);
const rowVirtualizer = useVirtualizer({
count: items.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 72,
});
return (
<div ref={parentRef} style={{ height: '100%', overflow: 'auto' }}>
<div style={{ height: rowVirtualizer.getTotalSize() }}>
{rowVirtualizer.getVirtualItems().map((virtualRow) => (
<div
key={items[virtualRow.index].id}
style={{
position: 'absolute',
top: virtualRow.start,
height: virtualRow.size,
}}
>
<ItemRow item={items[virtualRow.index]} />
</div>
))}
</div>
</div>
);
}
Key Takeaways
- Profile before optimizing — React DevTools Profiler shows where time goes
- Memoize judiciously — React.memo, useMemo, useCallback have overhead
- Code split aggressively — lazy load routes and heavy components
- Colocate state — move state down, only lift when needed
- Virtualize long lists — render visible items only
Performance is a feature. Treat it as a first-class concern in code review, and set budgets in CI to catch regressions before production.
END OF ARCHIVE →