React is fast by default, but it's easy to make it slow. Large component trees, unnecessary re-renders, and big bundles can degrade user experience. This guide covers practical techniques to identify and fix React performance issues.
Identifying Performance Problems
Before optimizing, find the actual problems. Premature optimization wastes time and adds complexity.
Signs of performance issues:
- Slow initial load (large bundle size)
- Laggy interactions (slow re-renders)
- Janky scrolling or animations
- High memory usage over time
Measurement tools:
- Browser DevTools Performance tab
- React DevTools Profiler
- Lighthouse audits
- Real user monitoring (Core Web Vitals)
Profile first, optimize second. The bottleneck is rarely where you expect.
React DevTools Profiler
The React DevTools Profiler shows what rendered, when, and why.
To use it:
- Install React DevTools browser extension
- Open DevTools and go to the Profiler tab
- Click Record, perform the interaction, click Stop
- Review the flame graph
What to look for:
- Components that render when they shouldn't
- Components that take a long time to render
- Renders triggered by state changes in unrelated components
The profiler shows "Why did this render?" for each component;state change, props change, or parent re-render. This tells you where to focus.
useMemo and useCallback Correctly
These hooks prevent recalculating values and recreating functions on every render. They're often misused.
useMemo memoizes computed values:
// Without useMemo - filters on every render
const filtered = items.filter(item => item.status === status);
// With useMemo - filters only when items or status change
const filtered = useMemo(
() => items.filter(item => item.status === status),
[items, status]
);
useCallback memoizes functions:
// Without useCallback - new function every render
const handleClick = () => {
console.log(item.id);
};
// With useCallback - same function unless item.id changes
const handleClick = useCallback(() => {
console.log(item.id);
}, [item.id]);
When to use them:
- useMemo: Expensive calculations, values passed to memoized children
- useCallback: Functions passed to memoized children, functions in dependency arrays
When NOT to use them:
- Simple calculations (comparison + memo overhead > computation)
- Functions not passed as props
- Everywhere "just in case"
The overhead of useMemo/useCallback isn't free. Use them intentionally.
React.memo for Component Memoization
React.memo prevents a component from re-rendering when its props haven't changed:
const UserCard = React.memo(function UserCard({ user, onClick }) {
return (
<div onClick={onClick}>
{user.name}
</div>
);
});
The parent can re-render freely; UserCard only re-renders if user or onClick change.
Important: This only works if props are actually stable. If the parent passes a new object or function every render, memo doesn't help:
// Bad - new onClick function every render, memo useless
<UserCard user={user} onClick={() => handleClick(user.id)} />
// Good - stable onClick function
const handleClick = useCallback(() => handleUserClick(user.id), [user.id]);
<UserCard user={user} onClick={handleClick} />
React.memo does shallow comparison by default. For complex props, you can provide a custom comparison function:
const UserCard = React.memo(UserCard, (prevProps, nextProps) => {
return prevProps.user.id === nextProps.user.id;
});
Code Splitting with Lazy Loading
Large bundles slow initial load. Code splitting loads code only when needed.
Route-based splitting (most common):
import { lazy, Suspense } from 'react';
const Dashboard = lazy(() => import('./Dashboard'));
const Settings = lazy(() => import('./Settings'));
function App() {
return (
<Suspense fallback={<Loading />}>
<Routes>
<Route path="/dashboard" element={<Dashboard />} />
<Route path="/settings" element={<Settings />} />
</Routes>
</Suspense>
);
}
Component-based splitting (for heavy components):
const HeavyChart = lazy(() => import('./HeavyChart'));
function Report() {
const [showChart, setShowChart] = useState(false);
return (
<div>
<button onClick={() => setShowChart(true)}>Show Chart</button>
{showChart && (
<Suspense fallback={<ChartSkeleton />}>
<HeavyChart />
</Suspense>
)}
</div>
);
}
Name your chunks for better debugging:
const Dashboard = lazy(() =>
import(/* webpackChunkName: "dashboard" */ './Dashboard')
);
Bundle Size Analysis
You can't optimize what you don't measure. Analyze your bundle:
# For webpack
npm install --save-dev webpack-bundle-analyzer
npx webpack-bundle-analyzer stats.json
# For Vite
npm install --save-dev rollup-plugin-visualizer
Common bundle size issues:
- Importing entire libraries when you need one function
- Multiple versions of the same library
- Large dependencies you don't actually use
- Not tree-shaking properly
Fixes:
// Bad - imports entire lodash
import _ from 'lodash';
_.debounce(fn, 100);
// Good - imports only debounce
import debounce from 'lodash/debounce';
debounce(fn, 100);
// Or use lodash-es for tree shaking
import { debounce } from 'lodash-es';
Consider lighter alternatives:
- date-fns instead of moment
- Native fetch instead of axios (for simple cases)
- CSS instead of JavaScript animation libraries
Virtual Lists for Large Data
Rendering thousands of items kills performance. Virtual lists render only what's visible:
import { FixedSizeList } from 'react-window';
function VirtualizedList({ items }) {
const Row = ({ index, style }) => (
<div style={style}>{items[index].name}</div>
);
return (
<FixedSizeList
height={400}
itemCount={items.length}
itemSize={35}
width="100%"
>
{Row}
</FixedSizeList>
);
}
Popular libraries:
- react-window (lightweight)
- react-virtualized (more features)
- @tanstack/react-virtual (headless, flexible)
Virtual lists work for any scrollable list with many items: tables, feeds, dropdowns with many options.
Server-Side Rendering Benefits
SSR sends rendered HTML to the browser, improving:
Time to First Paint: Users see content immediately, even before JavaScript loads.
SEO: Search engines can index your content without executing JavaScript.
Performance on slow devices: The server does the heavy lifting.
Next.js makes SSR straightforward. With React Server Components, you can mix server and client rendering at the component level:
// Server Component - runs on server, no JS sent to client
async function UserProfile({ userId }) {
const user = await db.users.find(userId);
return <div>{user.name}</div>;
}
// Client Component - interactive, runs in browser
'use client'
function LikeButton({ postId }) {
const [liked, setLiked] = useState(false);
return <button onClick={() => setLiked(!liked)}>Like</button>;
}
Quick Wins
Simple changes that often help:
Key props: Always use stable, unique keys for lists. Don't use array index.
Avoid inline objects in JSX:
// Bad - new object every render
<Component style={{ color: 'red' }} />
// Better - stable reference
const style = { color: 'red' };
<Component style={style} />
Debounce expensive operations:
const debouncedSearch = useMemo(
() => debounce(searchFn, 300),
[]
);
State colocation: Keep state close to where it's used. Global state that changes frequently causes widespread re-renders.
Avoid reconciliation: Conditional rendering is faster than hiding with CSS when components are expensive.
Conclusion
React performance optimization is about removing waste: unnecessary renders, unnecessary code, unnecessary work. Start by measuring to find real problems, then apply targeted fixes.
Most applications don't need aggressive optimization. Focus on actual user-facing issues: slow initial load, laggy interactions, poor Core Web Vitals. Fix those first.
The React team continues improving performance in the framework itself. Keep your React version updated, use the Profiler regularly, and optimize when measurement shows you need to.