React Performance Optimization

May 31, 2025

Overview

Performance in React isn’t just about passing a few props or toggling a bit of state—it’s about delivering a snappy, consistent user experience across your entire application. This post covers practical optimization techniques, from avoiding unnecessary re-renders to memoizing expensive calculations, complete with actual code you can run locally and measure.

Table of Contents

Common Bottlenecks
Benchmarking Tips
Minimizing Unnecessary Re-renders (Using `React.memo`)
The Problem
Example: Parent-Child Rendering
The Optimized Version
Avoiding Heavy Computations (Using useMemo)
The Problem
UnoptimizedFibDoubleState.jsx
OptimizedFibDoubleState.jsx
Handling Large Lists with Virtualization
Additional Tips
Distinguishing React.memo from useMemo
Conclusion
Key Takeaway

Common Bottlenecks
^

  1. Unnecessary Re-renders

    • Components re-render even if their props or state haven’t changed.
    • Large component trees can multiply the impact.
  2. Heavy Computations in the Render Cycle

    • If you run expensive logic on every render, the UI can stutter or lag.
    • Calculations like deep transformations, recursion, or data sorting can be especially costly.
  3. Rendering Giant Lists

    • Tables or lists with thousands of items can block the main thread, causing jank when scrolling.
  4. Overly Frequent State Updates

    • Updating state on every keystroke or mouse movement can flood React with re-renders.

Benchmarking Tips
^

Before optimizing, measure what’s slow:

  • React Profiler: Built into React DevTools, shows how long components take to render.
  • Console Timing: Use console.time() / console.timeEnd() around suspiciously heavy code.
  • Production Mode: Always test final builds, as dev mode can skew results (especially with React’s Strict Mode double-invocations).

Minimizing Unnecessary Re-renders (Using React.memo)
^

The Problem
^

If a parent component re-renders for any reason, all of its children render—even those that don’t rely on updated props or state.

Example: Parent-Child Rendering
^

// UnoptimizedParent.jsx
import React, { useState } from 'react';

export default function UnoptimizedParent() {
  const [count, setCount] = useState(0);

  const increment = () => setCount(prev => prev + 1);

  return (
    <div style={{ border: '1px solid red', padding: '1rem', margin: '1rem 0' }}>
      <h2>Unoptimized Parent</h2>
      <button onClick={increment}>Increment Parent Counter</button>
      <p>Parent Count: {count}</p>
      <ChildComponent />
    </div>
  );
}

function ChildComponent() {
  console.time('Child render');
  // Simulate some minor logic or side effect
  const now = performance.now();
  console.timeEnd('Child render');

  return <p>Child render at {Math.round(now)} ms</p>;
}

Every click re-renders ChildComponent, despite ChildComponent not using count.

The Optimized Version
^

// OptimizedParent.jsx
import React, { useState, memo } from 'react';

export default function OptimizedParent() {
  const [count, setCount] = useState(0);

  const increment = () => setCount(prev => prev + 1);

  return (
    <div style={{ border: '1px solid green', padding: '1rem', margin: '1rem 0' }}>
      <h2>Optimized Parent</h2>
      <button onClick={increment}>Increment Parent Counter</button>
      <p>Parent Count: {count}</p>
      <MemoizedChild />
    </div>
  );
}

// Wrap the child in memo
const MemoizedChild = memo(function ChildComponent() {
  console.time('Child render');
  const now = performance.now();
  console.timeEnd('Child render');

  return <p>Child render at {Math.round(now)} ms</p>;
});

Now MemoizedChild only re-renders if its own props change—which they don’t in this example. So each time you increment count, you’ll see Child render in the console only during the first mount (or if its props ever changed).

Avoiding Heavy Computations (Using useMemo)
^

The Problem
^

If a component does something computationally expensive (e.g., recursion, data formatting) every time it re-renders—even when the underlying input hasn’t changed—that’s wasted work.

Example A: The “Two-State” Scenario (Demonstrates Real Memo Benefit)
^

A common confusion arises when we only change the calculation’s dependency every time (like incrementing from 30 → 31 → 32). That re-triggers the heavy logic on each click, so useMemo offers no visible speedup. Instead, we want an example that forces re-renders without changing the expensive input.

UnoptimizedFibDoubleState.jsx
^

import React, { useState } from 'react';

function fibonacci(num) {
  if (num <= 1) return num;
  return fibonacci(num - 1) + fibonacci(num - 2);
}

export default function UnoptimizedFibDoubleState() {
  // The number we pass into fibonacci
  const [count, setCount] = useState(35);

  // Separate state that triggers re-renders but doesn't change `count`
  const [forceRenderCount, setForceRenderCount] = useState(0);

  console.time('UnoptimizedFib render');
  const fibValue = fibonacci(count); // Always recalculates, even if `count` didn't change
  console.timeEnd('UnoptimizedFib render');

  return (
    <div style={{ border: '2px solid red', padding: '1rem', margin: '1rem 0' }}>
      <h2>Unoptimized Fib (Double State)</h2>
      <p>Fibonacci({count}) = {fibValue}</p>
      <button onClick={() => setCount(count + 1)}>Increment Fibonacci Count</button>
      <button onClick={() => setForceRenderCount(forceRenderCount + 1)} style={{ marginLeft: '1rem' }}>
        Force Re-render
      </button>
      <p>forceRenderCount: {forceRenderCount}</p>
    </div>
  );
}

OptimizedFibDoubleState.jsx
^

import React, { useState, useMemo } from 'react';

function fibonacci(num) {
  if (num <= 1) return num;
  return fibonacci(num - 1) + fibonacci(num - 2);
}

export default function OptimizedFibDoubleState() {
  // The number we pass into fibonacci
  const [count, setCount] = useState(35);

  // Separate state that triggers re-renders but doesn't change `count`
  const [forceRenderCount, setForceRenderCount] = useState(0);

  console.time('OptimizedFib render');
  // Only recompute fibonacci if `count` actually changes
  const fibValue = useMemo(() => fibonacci(count), [count]);
  console.timeEnd('OptimizedFib render');

  return (
    <div style={{ border: '2px solid green', padding: '1rem', margin: '1rem 0' }}>
      <h2>Optimized Fib (Double State)</h2>
      <p>Fibonacci({count}) = {fibValue}</p>
      <button onClick={() => setCount(count + 1)}>Increment Fibonacci Count</button>
      <button onClick={() => setForceRenderCount(forceRenderCount + 1)} style={{ marginLeft: '1rem' }}>
        Force Re-render
      </button>
      <p>forceRenderCount: {forceRenderCount}</p>
    </div>
  );
}

How to See the Difference
^

  • Increment Fibonacci Count: Both unoptimized and optimized run the heavy calculation because count is changing. The times may be similar.
  • Force Re-render:
    • Unoptimized recalculates fibonacci(count) every time, even though count is unchanged.
    • Optimized reuses the memoized result, so you should see near-zero ms after the first calculation.

This scenario clearly illustrates the benefit of useMemo when re-renders happen for reasons unrelated to the expensive function’s actual input.

Handling Large Lists with Virtualization
^

Displaying thousands of rows in one go can cripple performance. Virtualization libraries, like react-window or react-virtualized, render only what’s visible.

// NonVirtualizedList.jsx
import React from 'react';

export default function NonVirtualizedList({ items }) {
  console.time('Render large list');
  const listItems = items.map((item, i) => <li key={i}>{item}</li>);
  console.timeEnd('Render large list');

  return <ul style={{ maxHeight: 300, overflowY: 'auto' }}>{listItems}</ul>;
}
// VirtualizedList.jsx
import React from 'react';
import { FixedSizeList as List } from 'react-window';

export default function VirtualizedList({ items }) {
  console.time('Render virtualized list');
  const Row = ({ index, style }) => <div style={style}>{items[index]}</div>;
  console.timeEnd('Render virtualized list');

  return (
    <List height={300} itemCount={items.length} itemSize={30} width={300}>
      {Row}
    </List>
  );
}

Benchmarks typically show big gains:

  • Non-Virtualized might take hundreds of ms to render 10k items.
  • Virtualized only renders visible rows, drastically reducing DOM nodes.

Additional Tips
^

  • Code Splitting: Lazy-load heavy routes or features so initial bundles are smaller.
  • Avoid Inline Object Creations: Re-creating objects in JSX can trigger re-renders when shallow prop checks fail.
  • Use Production Builds: npm run build + serve -s build ensures accurate performance measurements.
  • React Profiler: The Profiler tab in DevTools offers flame charts and commit logs, pinpointing slow spots in your component tree.

Distinguishing React.memo from useMemo
^

Both can skip unnecessary work, but they operate at different levels:

  • React.memo: Prevents a child component from re-rendering if its props haven’t changed. It’s about skipping entire component updates.
  • useMemo: Prevents an expensive function within a component from re-running unless specific dependencies change. It’s about caching purely computational work inside the same component.

These two strategies can complement each other:

  • Use React.memo to shield child components from parent re-renders.
  • Use useMemo to skip heavy calculations in the same component or the child itself.

Conclusion
^

React performance optimization is not a one-size-fits-all solution—rather, it’s a blend of tactics guided by real measurements:

  • Identify the bottlenecks (using React Profiler, console logs, or performance dev tools).
  • Optimize with known best practices (memoization, virtualization, code splitting, etc.).
  • Verify the actual impact—never assume an optimization helps unless the metrics confirm it.

By leveraging techniques like React.memo for child components, useMemo for expensive calculations, and virtualization for large lists, you can strike the right balance between flexibility, maintainability, and blazing-fast performance in your React apps.

Key Takeaway
^

Benchmark, iterate, and use the right tools—optimizing React is about precisely targeting slow spots and demonstrating real-world gains. With these techniques in your toolkit, you’ll deliver a smoother, more responsive experience for your users.