React Performance Optimization

Reverend Philip Dec 5, 2025 6 min read

Speed up your React applications with practical techniques for optimizing rendering and reducing bundle size.

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:

  1. Install React DevTools browser extension
  2. Open DevTools and go to the Profiler tab
  3. Click Record, perform the interaction, click Stop
  4. 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.

Share this article

Related Articles

Need help with your project?

Let's discuss how we can help you build reliable software.