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:
Consider a scenario where you're filtering a large list of items based on a status. Without memoization, this filtering operation runs on every single render, even when the underlying data hasn't changed.
// 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]
);
Notice how the dependency array [items, status] tells React exactly when to recalculate. If neither value changes between renders, React returns the cached result instead of running the filter again.
useCallback memoizes functions:
Function references change on every render by default, which can trigger unnecessary re-renders in child components that receive them as props.
// 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:
You can wrap any functional component with React.memo to skip re-renders when props remain the same. This is especially useful for components that receive stable props but have expensive render logic.
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:
Here's where useCallback becomes essential. Without it, you're defeating the purpose of React.memo entirely because the onClick reference changes on every parent render.
// 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:
When your props contain nested objects or you only care about specific property changes, a custom comparator gives you fine-grained control over when re-renders occur.
const UserCard = React.memo(UserCard, (prevProps, nextProps) => {
return prevProps.user.id === nextProps.user.id;
});
Be careful with custom comparators. Returning true means "props are equal, skip render." Getting this logic wrong can cause stale UI.
Code Splitting with Lazy Loading
Large bundles slow initial load. Code splitting loads code only when needed.
Route-based splitting (most common):
This is the most impactful form of code splitting. Users only download the JavaScript for the page they're visiting, dramatically reducing initial bundle size.
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>
);
}
The Suspense boundary catches the loading state while chunks download. You can nest multiple Suspense boundaries for more granular loading states.
Component-based splitting (for heavy components):
Sometimes a single page has a heavy component that not every user needs. You can lazy load it only when the user requests it, keeping the initial page load fast.
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>
);
}
Using a skeleton component as the fallback provides a better user experience than a generic spinner since users can anticipate the incoming content.
Name your chunks for better debugging:
Adding chunk names makes it much easier to identify which bundles are being loaded when analyzing network requests or debugging production issues.
const Dashboard = lazy(() =>
import(/* webpackChunkName: "dashboard" */ './Dashboard')
);
Bundle Size Analysis
You can't optimize what you don't measure. Analyze your bundle:
These tools generate visual maps of your bundle, showing exactly which dependencies consume the most space. Run this analysis before and after optimization attempts to verify your changes had the intended effect.
# 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:
The way you import a library can dramatically affect your bundle size. Named imports from the root of large libraries often pull in everything, while targeted imports only include what you need.
// 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';
The difference can be significant. Importing all of lodash adds around 70KB to your bundle, while importing just debounce adds only about 2KB.
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:
Instead of creating DOM nodes for every item in your list, virtual lists only render the items currently in the viewport plus a small buffer. This reduces memory usage and dramatically speeds up rendering for large datasets.
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>
);
}
The style prop is critical here. It contains absolute positioning that the library uses to place each row correctly. Always apply it to your row component's container element.
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 Components fetch data and render on the server, sending only HTML to the client. Client Components handle interactivity and run in the browser. You choose the right tool for each piece of your UI.
// 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>;
}
Notice the 'use client' directive. Without it, components are Server Components by default in the App Router. Only add the directive when you need browser APIs or interactivity.
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:
Inline objects create new references on every render, which can break memoization and trigger unnecessary re-renders in child components.
// Bad - new object every render
<Component style={{ color: 'red' }} />
// Better - stable reference
const style = { color: 'red' };
<Component style={style} />
Debounce expensive operations:
When handling user input that triggers expensive operations like API calls or heavy computations, debouncing prevents the operation from running until the user pauses.
const debouncedSearch = useMemo(
() => debounce(searchFn, 300),
[]
);
The empty dependency array ensures the debounced function is created once and maintains its internal timer across renders.
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.