React2025-01-22

React Performance Optimization: A Practical Guide

React applications tend to start fast and slow down gradually as features accumulate. A component that renders instantly with ten items becomes sluggish with a thousand. A form that felt snappy during development crawls under real user interactions. This guide covers practical techniques for identifying and fixing performance bottlenecks in React applications.

Understanding Re-renders

Before optimizing, understand what triggers re-renders. A React component re-renders when:

  1. Its state changes.
  2. Its parent re-renders (unless memoized).
  3. A context it consumes changes.

Not every re-render is a problem. React's reconciliation is fast, and most re-renders complete in under a millisecond. Optimization matters when re-renders cause visible lag, dropped frames, or delayed user interactions.

React.memo and useMemo

React.memo prevents a component from re-rendering when its props haven't changed. It performs a shallow comparison by default.

interface UserCardProps {
  name: string;
  email: string;
  avatar: string;
}

const UserCard = React.memo(function UserCard({ name, email, avatar }: UserCardProps) {
  return (
    <div className="user-card">
      <img src={avatar} alt={name} />
      <h3>{name}</h3>
      <p>{email}</p>
    </div>
  );
});

useMemo caches the result of an expensive computation between re-renders:

function AnalyticsDashboard({ transactions }: { transactions: Transaction[] }) {
  const summary = useMemo(() => {
    return transactions.reduce((acc, tx) => ({
      total: acc.total + tx.amount,
      count: acc.count + 1,
      average: (acc.total + tx.amount) / (acc.count + 1),
    }), { total: 0, count: 0, average: 0 });
  }, [transactions]);

  return <SummaryChart data={summary} />;
}

Use useMemo for genuinely expensive computations, not for trivial operations. Memoization itself has a cost: storing the previous value and comparing dependencies every render.

Virtualized Lists with react-window

Rendering thousands of DOM nodes kills performance. Virtualized lists render only the items visible in the viewport plus a small buffer.

import { FixedSizeList as List } from 'react-window';

interface LogEntry {
  id: string;
  timestamp: string;
  message: string;
}

function LogViewer({ logs }: { logs: LogEntry[] }) {
  const Row = ({ index, style }: { index: number; style: React.CSSProperties }) => (
    <div style={style} className="log-row">
      <span className="timestamp">{logs[index].timestamp}</span>
      <span className="message">{logs[index].message}</span>
    </div>
  );

  return (
    <List
      height={600}
      itemCount={logs.length}
      itemSize={35}
      width="100%"
    >
      {Row}
    </List>
  );
}

For items with varying heights, use VariableSizeList and provide a function that returns the height for each index. The react-virtuoso library handles variable heights automatically if measuring is impractical.

Code Splitting with lazy and Suspense

Large bundles slow down initial page loads. React's lazy function lets you split components into separate chunks that load on demand.

import { lazy, Suspense } from 'react';

const AdminPanel = lazy(() => import('./AdminPanel'));
const AnalyticsDashboard = lazy(() => import('./AnalyticsDashboard'));

function App() {
  return (
    <Suspense fallback={<LoadingSpinner />}>
      <Routes>
        <Route path="/admin" element={<AdminPanel />} />
        <Route path="/analytics" element={<AnalyticsDashboard />} />
        <Route path="/" element={<HomePage />} />
      </Routes>
    </Suspense>
  );
}

Split at route boundaries first since this provides the biggest impact with the least complexity. Then consider splitting heavy components that aren't immediately visible, like modals, charts, or below-the-fold content.

Using the React DevTools Profiler

The Profiler tab in React DevTools records what rendered, why it rendered, and how long each render took. To use it effectively:

  1. Open React DevTools and switch to the Profiler tab.
  2. Click Record, interact with your application, then stop recording.
  3. Examine the flamegraph. Components that render frequently or take a long time appear as wider bars.
  4. Click on a component to see why it re-rendered: "Props changed," "State changed," or "Parent rendered."

Enable "Record why each component rendered while profiling" in the Profiler settings for the most useful output. This tells you exactly which prop or state change triggered each render.

Avoiding Unnecessary Re-renders

Common patterns that cause wasteful re-renders:

Inline objects and arrays as props. Every render creates a new reference, defeating shallow comparison.

// Bad: new object every render
<UserList filters={{ role: 'admin', active: true }} />

// Good: stable reference
const adminFilters = useMemo(() => ({ role: 'admin', active: true }), []);
<UserList filters={adminFilters} />

Inline callback functions. Use useCallback when passing callbacks to memoized children.

// Bad: new function every render
<SearchInput onChange={(value) => setQuery(value)} />

// Good: stable reference
const handleChange = useCallback((value: string) => setQuery(value), []);
<SearchInput onChange={handleChange} />

Context that changes too frequently. When a context value changes, every consumer re-renders. Split contexts by update frequency.

// Instead of one large context
const AppContext = createContext<{ user: User; theme: Theme; notifications: Notification[] }>();

// Split into focused contexts
const UserContext = createContext<User>();
const ThemeContext = createContext<Theme>();
const NotificationContext = createContext<Notification[]>();

State Colocation

State should live as close to where it's used as possible. When state lives too high in the component tree, changes to that state re-render everything below.

// Before: search state lives in App, re-renders the entire tree
function App() {
  const [searchQuery, setSearchQuery] = useState('');
  return (
    <div>
      <SearchBar query={searchQuery} onChange={setSearchQuery} />
      <ExpensiveSidebar />
      <MainContent />
    </div>
  );
}

// After: search state lives in SearchBar, only re-renders what needs it
function App() {
  return (
    <div>
      <SearchBar />
      <ExpensiveSidebar />
      <MainContent />
    </div>
  );
}

function SearchBar() {
  const [searchQuery, setSearchQuery] = useState('');
  return <input value={searchQuery} onChange={(e) => setSearchQuery(e.target.value)} />;
}

This principle extends to derived state. If you can compute a value from existing props or state, do so during rendering instead of storing it in a separate state variable and syncing it with effects.

Measuring What Matters

Performance optimization without measurement is guesswork. Use these tools in combination:

  • React DevTools Profiler for component-level render analysis.
  • Chrome Performance tab for paint and layout timing.
  • Web Vitals (LCP, FID, CLS) for user-centric metrics.
  • Lighthouse for automated audits.

Profile with production builds. React's development mode includes extra checks that significantly slow rendering. What looks like a performance problem in development might be perfectly fine in production.

Focus on interactions that users actually notice: form input latency, list scrolling smoothness, page transition speed. A component that re-renders unnecessarily but completes in 0.1ms is not worth optimizing. A component that blocks the main thread for 200ms during a scroll event is.

© 2025 DevPractical. Practical guides for modern software engineering.