Performance Patterns and Memoization

· 7 min read · Updated March 27, 2026 · intermediate
react performance hooks memoization javascript

Every React component re-renders when its state changes, when it receives new props, or when its parent re-renders. For simple components this is fast enough that you never notice. But as your UI grows — long lists, deeply nested component trees, expensive calculations — unnecessary re-renders start hurting responsiveness.

Memoization is React’s answer. It lets you skip work when the result would be the same as last time. Three tools handle this: React.memo, useMemo, and useCallback.

Why Components Re-render

React re-renders a component whenever its state or props change. State lives inside the component, so changes there always trigger re-renders. Props come from the parent, which means a re-render in a parent bubbles down to all its children — whether they actually need to update or not.

Consider a parent with two children. One child (say, a counter) updates when the user clicks. The other child (a display of unrelated data) also re-renders, even though nothing it depends on changed. For small components this is fine. For a component that renders hundreds of rows, this waste compounds fast.

The three memoization tools attack this problem from different angles: React.memo skips child re-renders when props haven’t changed, useMemo skips expensive recalculations when dependencies are stable, and useCallback stabilizes function references so they don’t break React.memo.

Preventing Child Re-renders with React.memo

React.memo is a higher-order component that wraps a function component. It compares the new props to the previous props using shallow equality, and skips the re-render if nothing relevant changed.

import React from 'react';

const ExpensiveList = React.memo(({ items, onRemove }) => {
  console.log('ExpensiveList rendered');
  return (
    <ul>
      {items.map(item => (
        <li key={item.id}>
          {item.name}
          <button onClick={() => onRemove(item.id)}>Remove</button>
        </li>
      ))}
    </ul>
  );
});

React.memo takes an optional second argument — a custom comparison function called arePropsEqual. It receives the previous and next props, and returns true to skip re-rendering.

const MyComponent = React.memo(ExpensiveChild, (prevProps, nextProps) => {
  // Skip re-render when these specific props are equal
  return prevProps.userId === nextProps.userId;
});

Be careful with arePropsEqual. Returning true means “the props are equal, skip the render.” If you return false always, you defeat the memoization entirely — the component re-renders every time.

Memoizing Expensive Calculations with useMemo

useMemo caches the result of a computation and only recalculates when one of its dependencies changes. Use it when a calculation is genuinely expensive — sorting a large array, filtering a long list, deriving complex data from an object.

import { useMemo, useState } from 'react';

function DataTable({ items, filterText }) {
  // Only recomputes when items or filterText changes
  const filteredItems = useMemo(() => {
    console.log('Filtering...');
    return items.filter(item =>
      item.name.toLowerCase().includes(filterText.toLowerCase())
    );
  }, [items, filterText]);

  return (
    <ul>
      {filteredItems.map(item => (
        <li key={item.id}>{item.name}</li>
      ))}
    </ul>
  );
}

The dependency array is the trigger. When any value in it changes between renders, React re-runs your compute function and gets a fresh result. When nothing changes, React returns the cached value without running your function at all.

An empty dependency array means the value is computed once and cached forever:

const sortedConfig = useMemo(() => {
  return items.slice().sort((a, b) => a.name.localeCompare(b.name));
}, []); // Runs once on mount

Omitting the dependency array entirely means no memoization — the compute function runs on every render, equivalent to just calling it inline.

Stabilizing Callback References with useCallback

When you define a function inside a component, you get a new function object on every render. This breaks React.memo — the child sees a different prop reference even if the function logically does the same thing.

useCallback solves this by returning a stable reference. The function inside only gets a new identity when its dependencies change.

import { useCallback, useState } from 'react';

function Parent() {
  const [count, setCount] = useState(0);

  // Stable reference — only changes when count changes
  const handleIncrement = useCallback(() => {
    setCount(c => c + 1);
  }, []);

  return <Child onIncrement={handleIncrement} count={count} />;
}

Under the hood, useCallback(fn, deps) is equivalent to useMemo(() => fn, deps). The first argument is the function you want to memoize, and the second is the dependency array.

Pass callbacks to child components wrapped in React.memo. Without useCallback, the parent re-render creates a new function object, React.memo sees a changed prop, and the child re-renders anyway — defeating the purpose of memoizing the child in the first place.

Common Pitfalls

New object references on every render

This is the most frequent reason React.memo appears not to work. If you pass an object, array, or function as a prop, it must be stable across renders — otherwise React.memo always sees a change.

// Bad — new object on every render, breaks memoization
<MemoizedComponent style={{ color: 'red' }} />

// Good — memoize the object
const styles = useMemo(() => ({ color: 'red' }), []);
<MemoizedComponent style={styles} />

Missing or incorrect dependency arrays

useMemo and useCallback are only as correct as their dependency arrays. If you omit a value that the compute function or callback reads, you’ll get stale results. The ESLint rule react-hooks/exhaustive-deps catches most of these.

// Bad — threshold is read inside but missing from deps
const result = useMemo(() => {
  return data.filter(item => item.id > threshold);
}, []); // threshold missing!

// Good
const result = useMemo(() => {
  return data.filter(item => item.id > threshold);
}, [data, threshold]);

useCallback without React.memo

useCallback only helps when the child component is also memoized. If the child re-renders regardless, stabilizing the callback reference buys you nothing.

// This is pointless — Child re-renders because it's not memoized
const handleClick = useCallback(() => doThing(), []);
<Child onClick={handleClick} /> // Child re-renders anyway

When Not to Memoize

Memoization isn’t free. It adds overhead — storing cached values, comparing dependencies, allocating memory for the cache. For cheap computations this cost can exceed the savings.

// Not worth memoizing — this is just a number doubling
const doubled = useMemo(() => count * 2, [count]);

A good heuristic: memoize when the compute function does something measurably slow — an array filter/map on thousands of items, a sort, a regex over a large string, a recursive calculation. For simple arithmetic or string operations, just do the computation inline.

Similarly, if a component has no memoized children, React.memo wrapping it probably won’t help. The parent’s re-render will still reach it. Use React.memo on components that re-render often but receive the same props most of the time.

Putting It Together

Here’s a complete example that combines all three tools:

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

const ItemList = React.memo(({ items, onRemove }) => {
  console.log('ItemList rendered');
  return (
    <ul>
      {items.map(item => (
        <li key={item.id}>
          {item.name}
          <button onClick={() => onRemove(item.id)}>Remove</button>
        </li>
      ))}
    </ul>
  );
});

function App() {
  const [items, setItems] = useState([
    { id: 1, name: 'Alice' },
    { id: 2, name: 'Bob' },
    { id: 3, name: 'Carol' },
  ]);
  const [query, setQuery] = useState('');

  // Stable callback — only recreated when items changes
  const handleRemove = useCallback((id) => {
    setItems(prev => prev.filter(item => item.id !== id));
  }, []);

  // Stable filtered list — recalculates only when items or query changes
  const filtered = useMemo(() => {
    return items.filter(item =>
      item.name.toLowerCase().includes(query.toLowerCase())
    );
  }, [items, query]);

  return (
    <div>
      <input
        value={query}
        onChange={e => setQuery(e.target.value)}
        placeholder="Filter..."
      />
      <ItemList items={filtered} onRemove={handleRemove} />
    </div>
  );
}

React.memo on ItemList means it skips rendering when items and onRemove are both stable. useCallback keeps handleRemove stable even when App re-renders from query changes. useMemo recalculates filtered only when items or query changes, not on every handleRemove call.

The result: each piece of work runs only when it genuinely needs to — no more, no less.

Summary

React.memo prevents child re-renders when props haven’t changed. useMemo caches the result of an expensive calculation across renders. useCallback stabilizes function references so they don’t break React.memo on child components.

The key insight is that memoization targets a specific kind of waste: re-computing things that haven’t changed, or re-rendering components that received the same inputs. Before reaching for these tools, profile your app to confirm the waste exists. For cheap computations or components without memoized children, the overhead of memoization can outweigh the benefits.

See Also