React Performance: 8 Fixes That Actually Matter (2026)
Forget premature memoization. These are the optimizations that move the needle on real apps.
Before You Optimize Anything
Step 1: Measure
Step 2: Identify the bottleneck
Step 3: Fix ONLY the bottleneck
Step 4: Measure again
Step 5: Stop optimizing
If you can't measure it, don't optimize it. React DevTools Profiler is your friend.
Fix 1: Stop Re-Creating Objects in Render
// ❌ BAD: New object every render
function UserList() {
return users.map(user => (
<UserCard
key={user.id}
style={{ padding: 16, margin: 8 }} // New object every time
onClick={() => handleDelete(user.id)} // New function every time
config={{ theme: 'dark', size: 'lg' }} // New object every time
/>
));
}
// ✅ GOOD: Stable references
const CARD_STYLE = { padding: 16, margin: 8 };
const CARD_CONFIG = { theme: 'dark', size: 'lg' };
function UserList() {
return users.map(user => (
<UserCard
key={user.id}
style={CARD_STYLE}
onClick={() => handleDelete(user.id)}
config={CARD_CONFIG}
/>
));
}
Impact: Reduces unnecessary re-renders of child components.
Fix 2: Move Expensive Calculations to useMemo (Sparingly)
// ❌ BAD: Re-calculates on every render
function Dashboard({ orders }) {
const stats = {
total: orders.reduce((sum, o) => sum + o.total, 0),
average: orders.reduce((sum, o) => sum + o.total, 0) / orders.length,
topProduct: getMostOrderedProduct(orders), // Expensive!
byCategory: groupByCategory(orders), // Expensive!
};
return <StatsView stats={stats} />;
}
// ✅ GOOD: Memoize expensive calculations
function Dashboard({ orders }) {
const stats = useMemo(() => ({
total: orders.reduce((sum, o) => sum + o.total, 0),
average: orders.reduce((sum, o) => sum + o.total, 0) / (orders.length || 1),
topProduct: getMostOrderedProduct(orders),
byCategory: groupByCategory(orders),
}), [orders]);
return <StatsView stats={stats} />;
}
When to use: Only when the calculation is actually slow (>1ms). useMemo itself has overhead.
Fix 3: Virtualize Long Lists
// ❌ BAD: Renders ALL items (1000+ DOM nodes)
function UserList({ users }) {
return (
<div className="user-list">
{users.map(user => <UserCard key={user.id} user={user} />)}
</div>
);
}
// ✅ GOOD: Only renders visible items
import { useVirtualizer } from '@tanstack/react-virtual';
function UserList({ users }) {
const parentRef = useRef(null);
const virtualizer = useVirtualizer({
count: users.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 80, // Estimated item height
overscan: 5, // Render 5 extra items above/below viewport
});
return (
<div ref={parentRef} style={{ height: '600px', overflow: 'auto' }}>
<div style={{ height: virtualizer.getTotalSize(), position: 'relative' }}>
{virtualizer.getVirtualItems().map(virtualRow => (
<div
key={virtualRow.key}
style={{
position: 'absolute',
top: virtualRow.start,
height: virtualRow.size,
}}
>
<UserCard user={users[virtualRow.index]} />
</div>
))}
</div>
</div>
);
}
Impact: 1000 items → 20 DOM nodes. Massive difference.
Fix 4: Lazy Load Routes
// ❌ BAD: All code loaded upfront
import Dashboard from './pages/Dashboard';
import Settings from './pages/Settings';
import Reports from './pages/Reports';
const routes = [
{ path: '/dashboard', element: <Dashboard /> },
{ path: '/settings', element: <Settings /> },
{ path: '/reports', element: <Reports /> },
];
// ✅ GOOD: Code-split by route
import { lazy, Suspense } from 'react';
const Dashboard = lazy(() => import('./pages/Dashboard'));
const Settings = lazy(() => import('./pages/Settings'));
const Reports = lazy(() => import('./pages/Reports'));
const routes = [
{
path: '/dashboard',
element: (
<Suspense fallback={<Loading />}>
<Dashboard />
</Suspense>
)
},
// ...
];
Impact: Initial bundle size drops 40-70% on typical apps.
Fix 5: Batch State Updates
// ❌ BAD: Multiple renders
function handleFormSubmit(data) {
setFirstName(data.firstName); // Render 1
setLastName(data.lastName); // Render 2
setEmail(data.email); // Render 3
setPhone(data.phone); // Render 4
setSaving(true); // Render 5
}
// ✅ GOOD: Single render
function handleFormSubmit(data) {
setSaving(true);
// React 18 batches these automatically
setFirstName(data.firstName);
setLastName(data.lastName);
setEmail(data.email);
setPhone(data.phone);
}
// ✅ BETTER: Single state update
function handleFormSubmit(data) {
setSaving(true);
setFormData(prev => ({
...prev,
...data,
}));
}
Note: React 18 auto-batches most state updates. But in async callbacks, use flushSync or combine into one setState if needed.
Fix 6: Use React.memo Correctly
// ❌ WRONG: Wrapping everything
const UserCard = React.memo(function UserCard({ user }) {
return <div>{user.name}</div>;
});
// This is a micro-optimization if UserCard is fast to render
// ✅ RIGHT: Only wrap expensive components
const ExpensiveChart = React.memo(function ExpensiveChart({ data }) {
// This component does heavy canvas rendering
return <CanvasChart data={data} />;
}, (prevProps, nextProps) => {
// Custom comparison — only re-render if data actually changed
return prevProps.data.hash === nextProps.data.hash;
});
Rule of thumb: If the component renders in <1ms, React.memo adds more overhead than it saves.
Fix 7: Debounce Search Inputs
import { useState, useEffect, useMemo } from 'react';
import { useDebounce } from './hooks'; // or use-debounce library
function SearchUsers() {
const [query, setQuery] = useState('');
const debouncedQuery = useDebounce(query, 300);
// This only fires when user stops typing for 300ms
const { data: users, isLoading } = useQuery({
queryKey: ['users', debouncedQuery],
queryFn: () => searchUsers(debouncedQuery),
enabled: debouncedQuery.length > 2,
});
return (
<input
value={query}
onChange={e => setQuery(e.target.value)}
placeholder="Search users..."
/>
);
}
// Simple debounce hook
function useDebounce(value, delay) {
const [debouncedValue, setDebouncedValue] = useState(value);
useEffect(() => {
const timer = setTimeout(() => setDebouncedValue(value), delay);
return () => clearTimeout(timer);
}, [value, delay]);
return debouncedValue;
}
Fix 8: Image Optimization
// ❌ BAD: Raw image loading
<img src="/hero.jpg" alt="Hero" />
// ✅ GOOD: Lazy loaded + placeholder
import Image from 'next/image'; // Next.js
// OR
import { LazyLoadImage } from 'react-lazy-load-image-component';
<LazyLoadImage
src="/hero.jpg"
alt="Hero"
placeholderSrc="/hero-tiny.jpg" // 20x20 blurred version
effect="blur"
width={800}
height={400}
/>
Impact: Images are often 60-80% of page weight. Lazy loading + proper sizing = massive improvement.
What NOT to Do
| "Optimization" | Why It's a Waste |
|---|---|
useCallback for every function |
Overhead > benefit in most cases |
React.memo on leaf components |
Memoization cost > render cost |
useMemo for simple calculations |
Just compute it, it's faster |
| Moving everything to Web Workers | Communication overhead |
| Premature code splitting | Split at route boundaries first |
The Performance Checklist
- Profile with React DevTools before optimizing anything
- Virtualize lists > 100 items
- Lazy load routes
- Batch state updates
- Stable object/function references in render
- Debounce search inputs
- Optimize images (lazy load, proper sizing, WebP)
-
React.memo+useMemoonly where profiling shows re-renders
The 80/20 rule: 80% of performance issues come from 20% of your code. Find that 20% and fix it. Leave the rest alone.
Follow @armorbreak for more frontend performance tips.
Top comments (0)