React.memo & Performance

Difficulty: Intermediate

Question

How does React.memo work? When should you optimize with it, and when is it unnecessary?

Answer

React.memo and performance optimization. This is a topic where I see two extremes: developers who never optimize and end up with slow apps, and developers who sprinkle React.memo and useMemo on everything 'just in case' and end up with code that's harder to read and sometimes even slower. Let me help you find the right balance.

First, let's understand what actually happens when React re-renders. When a component's state changes, React re-renders that component AND all of its children. Not just the children whose props changed. ALL of them. This is by design. React's philosophy is: re-render everything, and let the reconciliation algorithm figure out which DOM nodes actually need to change. For most components, this is perfectly fast. Re-running the function and generating the virtual DOM is cheap. The expensive part is actual DOM manipulation, and React's diffing algorithm minimizes that.

But sometimes, re-rendering a child component is genuinely wasteful. Imagine a Dashboard component with a text input for a search filter. Every keystroke changes the state and re-renders Dashboard. Dashboard also renders a DataTable with 10,000 rows. The DataTable's props (the data) haven't changed, but React re-renders it anyway because its parent re-rendered. That DataTable re-render takes 50ms. At 60fps, you need each frame under 16ms. The user types and the input lags. That's a real performance problem.

React.memo solves this. It's a higher-order component that wraps your function component and adds a props comparison step before re-rendering. When the parent re-renders, React checks: did the props change? If they're the same as last time (using shallow comparison), React skips re-rendering the memoized component and reuses the last result. For our DataTable example, wrapping it in React.memo means typing in the search input doesn't re-render the DataTable, because its props (the data array) haven't changed.

Shallow comparison means React compares each prop using ===. For primitive values (strings, numbers, booleans), this works perfectly: 'hello' === 'hello' is true. For objects and arrays, === checks reference identity, not deep equality: {a: 1} === {a: 1} is false because they're two different objects in memory, even though they look the same.

This is the source of the most common React.memo pitfall. If you wrap a component in React.memo but pass it inline objects or functions as props, the memo is completely useless. Every time the parent renders, () => console.log('click') creates a new function and { padding: 16 } creates a new object. React.memo sees these as different props (because the references changed) and re-renders anyway. You added the overhead of comparison without getting any benefit.

To make React.memo effective with object and function props, you need to stabilize those references with useMemo (for objects/computed values) and useCallback (for functions). This is why these three tools often come together: React.memo on the child, useCallback for function props, useMemo for object props.

You can also provide a custom comparison function as the second argument to React.memo. This gives you fine-grained control: you might want to re-render only when specific props change, ignoring others. For example, a chart component might re-render only when the data or chart type changes, but not when an onHover callback changes. The custom comparator receives prevProps and nextProps and returns true if they're equal (skip re-render) or false if they're different (re-render).

Now, here's the key question: when should you actually use React.memo? Not everywhere. It has costs:

- Memory: React stores the previous props and render result to compare against. - CPU: The shallow comparison runs on every render of the parent, even when props DID change (in which case you paid the comparison cost AND the re-render cost). - Code complexity: Your team needs to understand the memoization chain. If someone later adds an inline function prop without useCallback, the memo silently becomes useless.

My rules of thumb:

1. The component renders frequently with the same props. A table that re-renders every keystroke in a sibling input? Great candidate. A component that renders once on page load? Don't bother.

2. The component is expensive to render. A component that does heavy computation, renders a large DOM tree, or triggers expensive CSS (like complex grid layouts)? Worth memoizing. A component that renders a single paragraph? The comparison costs more than the render.

3. You've actually measured a problem. This is the most important rule. Open React DevTools, go to the Profiler tab, record an interaction, and look at which components are re-rendering and how long they take. Don't guess. Measure. You'll often be surprised: the component you thought was slow is fine, and the bottleneck is somewhere you didn't expect.

The React team is building something called the React Compiler (formerly known as React Forget) that aims to automatically add memoization where it's beneficial, at compile time. When it's widely available, manual React.memo, useMemo, and useCallback will become much less necessary. The compiler analyzes your code and inserts the optimal memoization automatically. This is the future direction of React performance optimization.

Until then, the professional approach is: write your code without optimization first. If it's slow, profile with React DevTools. Identify the expensive renders. Apply React.memo (with useCallback and useMemo for their props) surgically to the components that actually matter. This is the 'measure first, optimize second' philosophy, and it's what distinguishes experienced developers from those who cargo-cult performance patterns.

For interviews, the key message is: 'I would profile with React DevTools before adding React.memo anywhere.' Then walk through a concrete example where React.memo helps (expensive child with stable props) and one where it doesn't (cheap component or unstable props). That balanced judgment impresses interviewers.

Code examples

When React.memo Helps (and Doesn't)

import { memo, useState, useCallback } from 'react';

// GOOD use: Expensive component with stable props
const DataTable = memo(function DataTable({ rows, columns }) {
  console.log('DataTable rendered - expensive!');
  return (
    <table>
      <thead>
        <tr>{columns.map(col => <th key={col.key}>{col.label}</th>)}</tr>
      </thead>
      <tbody>
        {rows.map(row => (
          <tr key={row.id}>
            {columns.map(col => <td key={col.key}>{row[col.key]}</td>)}
          </tr>
        ))}
      </tbody>
    </table>
  );
});

// BAD use: Simple component, renders fast
const Label = memo(function Label({ text }) {
  // Comparison cost > render cost
  return <span>{text}</span>;
});

function Dashboard() {
  const [filter, setFilter] = useState('');
  const [rows] = useState(generateLargeDataset());
  const [columns] = useState(tableColumns);

  return (
    <div>
      <input value={filter} onChange={e => setFilter(e.target.value)} />
      {/* DataTable won't re-render on every keystroke */}
      <DataTable rows={rows} columns={columns} />
    </div>
  );
}

React.memo shines when an expensive component has stable props. For the DataTable, skipping re-renders on every keystroke is a significant performance win. For a simple Label, the comparison overhead is not worth it.

Common Pitfall: Unstable Props

const UserCard = memo(function UserCard({ user, onClick, style }) {
  console.log('UserCard rendered');
  return (
    <div style={style} onClick={onClick}>
      <h3>{user.name}</h3>
      <p>{user.email}</p>
    </div>
  );
});

// BAD: memo is useless because props are new every render
function UserList({ users }) {
  return users.map(user => (
    <UserCard
      key={user.id}
      user={user}
      onClick={() => console.log(user.id)}  // New function every render!
      style={{ padding: 16 }}               // New object every render!
    />
  ));
}

// GOOD: Stabilize props with useCallback and useMemo
function UserList({ users }) {
  const handleClick = useCallback((userId) => {
    console.log(userId);
  }, []);

  const cardStyle = useMemo(() => ({ padding: 16 }), []);

  return users.map(user => (
    <UserCard
      key={user.id}
      user={user}
      onClick={() => handleClick(user.id)}
      style={cardStyle}
    />
  ));
}

React.memo is wasted if you pass new object/function references on every render. Stabilize them with useMemo and useCallback, or restructure to avoid inline objects.

Custom Comparison and Profiling

// Custom comparison function
const HeavyChart = memo(
  function HeavyChart({ data, config, onHover }) {
    // Expensive SVG rendering
    return <svg>{/* complex chart */}</svg>;
  },
  // Custom areEqual: return true to skip re-render
  (prevProps, nextProps) => {
    // Only re-render if data length or config changed
    // Ignore onHover changes (it's a callback)
    return (
      prevProps.data.length === nextProps.data.length &&
      prevProps.config.type === nextProps.config.type &&
      prevProps.config.color === nextProps.config.color
    );
  }
);

// Profiling to find what needs optimization
import { Profiler } from 'react';

function onRender(id, phase, actualDuration) {
  console.log(`${id} ${phase}: ${actualDuration.toFixed(2)}ms`);
}

function App() {
  return (
    <Profiler id="Dashboard" onRender={onRender}>
      <Dashboard />
    </Profiler>
  );
}

// React DevTools Profiler is even better:
// 1. Open React DevTools -> Profiler tab
// 2. Click Record, interact with your app
// 3. See which components re-rendered and why
// 4. Identify expensive renders to optimize

Custom comparison functions give fine-grained control over when to re-render. The Profiler API and React DevTools help identify which components actually need optimization.

Key points

Concepts covered

React.memo, Re-renders, Profiling, Performance Optimization