React Performance Optimization: Tips and Techniques
React is fast by default for most applications. But as component trees grow, as data fetching becomes more complex, and as UI interactions multiply, performance issues creep in. The good news: React gives you precise tools to address each type of problem. This guide covers the most impactful techniques with practical examples.
Profile Before Optimizing
The single most important rule: measure first. React DevTools Profiler shows you exactly which components re-render and why. Install it as a browser extension and use the Profiler tab to record interactions before making any changes.
Common problems it reveals:
- Components re-rendering when their props have not changed
- Large component trees re-rendering due to a state update deep in one branch
- Expensive calculations running on every render
Prevent Unnecessary Re-Renders with React.memo
By default, React re-renders a component whenever its parent re-renders — even if the component's props have not changed. React.memo prevents this with a shallow prop comparison.
// Without memo: re-renders every time parent renders
function ProjectCard({ project }) {
return <div>{project.name}</div>;
}
// With memo: only re-renders when project prop changes
const ProjectCard = React.memo(function ProjectCard({ project }) {
return <div>{project.name}</div>;
});Only apply React.memo to components that are genuinely expensive to render and receive stable props. Adding it everywhere is not free — the comparison itself has a cost.
Memoize Expensive Calculations with useMemo
useMemo caches the result of a calculation between renders, recomputing only when its dependencies change.
function Dashboard({ deployments }) {
// Without useMemo: recalculates on every render
// With useMemo: recalculates only when `deployments` changes
const stats = useMemo(() => ({
total: deployments.length,
running: deployments.filter(d => d.status === 'RUNNING').length,
failed: deployments.filter(d => d.status === 'FAILED').length,
}), [deployments]);
return <StatsBar stats={stats} />;
}Stabilize Callbacks with useCallback
Functions defined inside a component are recreated on every render. When passed as props, this causes child components (even memoized ones) to re-render unnecessarily. useCallback returns a stable function reference.
function ProjectList({ onDelete }) {
// onDelete is stable — ProjectCard won't re-render when parent does
const handleDelete = useCallback((id) => {
onDelete(id);
}, [onDelete]);
return projects.map(p => (
<ProjectCard key={p.id} project={p} onDelete={handleDelete} />
));
}Code Splitting with React.lazy
Do not ship your entire application on the initial load. Split large routes into separate bundles that load on demand.
import React, { Suspense } from 'react';
const Dashboard = React.lazy(() => import('./views/Dashboard'));
const Settings = React.lazy(() => import('./views/Settings'));
const Monitoring = React.lazy(() => import('./views/Monitoring'));
function App() {
return (
<Suspense fallback={<PageLoader />}>
<Routes>
<Route path="/dashboard" element={<Dashboard />} />
<Route path="/settings" element={<Settings />} />
<Route path="/monitoring" element={<Monitoring />} />
</Routes>
</Suspense>
);
}This keeps the initial bundle small and only loads each view when the user navigates to it.
Virtualize Long Lists
Rendering 1,000 DOM nodes at once is slow regardless of how optimized your components are. Use windowing/virtualization to render only the visible rows.
import { FixedSizeList as List } from 'react-window';
function DeploymentLog({ entries }) {
return (
<List
height={600}
itemCount={entries.length}
itemSize={40}
width="100%"
>
{({ index, style }) => (
<div style={style}>{entries[index].message}</div>
)}
</List>
);
}react-window renders only the items in the viewport, making lists of thousands of items feel instant.
Optimize Context Usage
React Context re-renders every consumer when the context value changes. Avoid putting frequently changing state (like real-time metrics) in a context that also provides stable configuration values.
// Bad: one big context causes all consumers to re-render on any change
const AppContext = React.createContext({ user, theme, notifications, metrics });
// Better: split into multiple contexts with different update frequencies
const UserContext = React.createContext(user); // rarely changes
const MetricsContext = React.createContext(metrics); // changes oftenImage and Asset Optimization
- Use
loading="lazy"on images below the fold - Set explicit
widthandheightto prevent layout shift (CLS) - Use WebP or AVIF formats
- Preload critical above-the-fold images with
Deploy a Static React Build
Building your React app for production (npm run build) generates minified, tree-shaken bundles with content hashes. Deploy the resulting /build directory as a static site — no server required for the frontend itself.
[PandaStack](https://dashboard.pandastack.io) supports static site deployments, so you can host your React build with minimal configuration and get the benefit of a CDN-backed delivery layer for all your static assets.
Summary
| Technique | Problem It Solves |
|---|---|
React.memo | Prevents re-renders when props unchanged |
useMemo | Avoids expensive recalculations |
useCallback | Stabilizes function references |
React.lazy | Reduces initial bundle size |
react-window | Makes long lists fast |
| Split Context | Limits context re-render blast radius |
Start with the Profiler, identify your actual bottleneck, then apply the technique that addresses it. Premature optimization wastes time; targeted optimization has enormous impact.