Skip to content
Codeloom
React

React Performance: Memo, useMemo, and useCallback Guide

Optimize React performance with React.memo, useMemo, and useCallback. Learn when to use each, how to profile, and avoid common pitfalls.

·8 min read · By Codeloom
Intermediate 16 min read

What you'll learn

  • How React.memo prevents unnecessary re-renders
  • When useMemo and useCallback actually help
  • Profiling React apps to find real bottlenecks
  • Common optimization mistakes to avoid

Prerequisites

  • React components and hooks
  • Understanding of JavaScript reference equality

Performance optimization in React starts with understanding how rendering works. React re-renders a component whenever its state changes or its parent re-renders. Most of the time this is fast enough. But when it is not, React.memo, useMemo, and useCallback give you control over what gets recalculated and what gets skipped.

The catch is that these tools have costs. Using them everywhere actually makes performance worse. This guide teaches you when they help and when they hurt.

How React Rendering Works

When state changes in a component, React re-renders that component and all of its children. This does not mean the DOM updates for every child. React compares the new virtual DOM with the old one and only touches the real DOM where differences exist.

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

  return (
    <div>
      <button onClick={() => setCount(c => c + 1)}>
        Count: {count}
      </button>
      <ExpensiveChild /> {/* Re-renders every click even though nothing changed */}
      <AnotherChild />  {/* Also re-renders */}
    </div>
  );
}

Both children re-render when count changes, even though they do not use count. For simple components this is fine. React’s diffing is fast. But if ExpensiveChild does heavy computation or renders thousands of elements, you want to skip unnecessary re-renders.

React.memo: Skipping Re-renders

React.memo wraps a component and skips re-rendering if its props have not changed:

const ExpensiveChild = React.memo(function ExpensiveChild({ data }) {
  // Only re-renders when `data` changes (shallow comparison)
  return (
    <ul>
      {data.map(item => (
        <li key={item.id}>{processItem(item)}</li>
      ))}
    </ul>
  );
});

React.memo performs a shallow comparison of props. For primitive values (strings, numbers, booleans), this works perfectly. For objects and arrays, it compares references.

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

  // This object is created fresh every render - new reference each time
  const style = { color: 'blue' };

  return (
    <div>
      <button onClick={() => setCount(c => c + 1)}>Count: {count}</button>
      {/* React.memo won't help here because style is a new object every render */}
      <MemoizedChild style={style} />
    </div>
  );
}

This is where useMemo comes in.

useMemo: Caching Computed Values

useMemo caches the result of a computation between renders. It only recalculates when its dependencies change:

function ProductList({ products, filter }) {
  // Only recomputes when products or filter change
  const filteredProducts = useMemo(() => {
    return products.filter(product =>
      product.name.toLowerCase().includes(filter.toLowerCase())
    );
  }, [products, filter]);

  return (
    <ul>
      {filteredProducts.map(product => (
        <li key={product.id}>{product.name} - ${product.price}</li>
      ))}
    </ul>
  );
}

Use useMemo for two scenarios: expensive computations and stable references.

Expensive computations:

function Analytics({ transactions }) {
  const stats = useMemo(() => {
    // Imagine this processes 10,000+ transactions
    const total = transactions.reduce((sum, t) => sum + t.amount, 0);
    const average = total / transactions.length;
    const max = Math.max(...transactions.map(t => t.amount));
    const min = Math.min(...transactions.map(t => t.amount));
    const sorted = [...transactions].sort((a, b) => b.amount - a.amount);
    const topFive = sorted.slice(0, 5);

    return { total, average, max, min, topFive };
  }, [transactions]);

  return (
    <div>
      <p>Total: ${stats.total}</p>
      <p>Average: ${stats.average.toFixed(2)}</p>
    </div>
  );
}

Stable references for child components:

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

  // Stable reference - only changes when name changes
  const config = useMemo(() => ({
    displayName: name.toUpperCase(),
    showAvatar: name.length > 0,
  }), [name]);

  return (
    <div>
      <button onClick={() => setCount(c => c + 1)}>Count: {count}</button>
      <input value={name} onChange={e => setName(e.target.value)} />
      <MemoizedProfile config={config} />
    </div>
  );
}

Now MemoizedProfile only re-renders when name changes, not when count changes.

useCallback: Caching Functions

useCallback is useMemo for functions. It returns the same function reference between renders unless its dependencies change:

function TodoList() {
  const [todos, setTodos] = useState([]);

  // Without useCallback, this is a new function every render
  // MemoizedTodoItem would re-render every time
  const handleDelete = useCallback((id: number) => {
    setTodos(prev => prev.filter(todo => todo.id !== id));
  }, []);

  const handleToggle = useCallback((id: number) => {
    setTodos(prev =>
      prev.map(todo =>
        todo.id === id ? { ...todo, done: !todo.done } : todo
      )
    );
  }, []);

  return (
    <ul>
      {todos.map(todo => (
        <MemoizedTodoItem
          key={todo.id}
          todo={todo}
          onDelete={handleDelete}
          onToggle={handleToggle}
        />
      ))}
    </ul>
  );
}

const MemoizedTodoItem = React.memo(function TodoItem({
  todo,
  onDelete,
  onToggle,
}: TodoItemProps) {
  return (
    <li>
      <input
        type="checkbox"
        checked={todo.done}
        onChange={() => onToggle(todo.id)}
      />
      <span>{todo.text}</span>
      <button onClick={() => onDelete(todo.id)}>Delete</button>
    </li>
  );
});

Without useCallback, every render creates new handleDelete and handleToggle functions. Since React.memo compares props by reference, the memoized TodoItem would re-render anyway, making React.memo useless.

Profiling Before Optimizing

Never optimize without measuring first. React DevTools has a Profiler that shows you exactly which components re-render and how long they take.

import { Profiler } from 'react';

function onRenderCallback(
  id,
  phase,
  actualDuration,
  baseDuration,
  startTime,
  commitTime
) {
  console.log(`${id} ${phase}: ${actualDuration.toFixed(2)}ms`);
}

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

Steps to profile:

  1. Open React DevTools and switch to the Profiler tab.
  2. Click “Record” and interact with your app.
  3. Stop recording and examine the flame chart.
  4. Look for components that re-render frequently with high render times.
  5. Only optimize those specific components.

When NOT to Optimize

Most components do not need memoization. Here are cases where adding React.memo, useMemo, or useCallback makes things worse:

Simple components that render fast:

// Don't bother memoizing this
function Label({ text }) {
  return <span className="label">{text}</span>;
}

The overhead of comparing props is more expensive than just re-rendering.

Values that change every render anyway:

function BadExample({ items }) {
  // Useless - items changes every render so useMemo recalculates every time
  const sorted = useMemo(
    () => [...items].sort((a, b) => a.name.localeCompare(b.name)),
    [items]  // If parent creates a new array each render, this never caches
  );
}

useCallback without React.memo on the child:

function Parent() {
  // Pointless - Child doesn't use React.memo so it re-renders anyway
  const handleClick = useCallback(() => {
    console.log('clicked');
  }, []);

  return <Child onClick={handleClick} />;
}

Optimizing Lists

Lists are the most common performance bottleneck. When rendering hundreds of items, optimize the list item, not the list container:

const MemoizedRow = React.memo(function Row({ item, onSelect }) {
  return (
    <tr onClick={() => onSelect(item.id)}>
      <td>{item.name}</td>
      <td>{item.email}</td>
      <td>{item.role}</td>
    </tr>
  );
});

function DataTable({ data }) {
  const [selectedId, setSelectedId] = useState(null);

  const handleSelect = useCallback((id: number) => {
    setSelectedId(id);
  }, []);

  return (
    <table>
      <tbody>
        {data.map(item => (
          <MemoizedRow
            key={item.id}
            item={item}
            onSelect={handleSelect}
          />
        ))}
      </tbody>
    </table>
  );
}

For very large lists (1000+ items), consider virtualization with libraries like @tanstack/react-virtual instead of memoization. Virtualization only renders items visible in the viewport.

State Structure Optimization

Sometimes the best optimization is restructuring your state to reduce the blast radius of updates:

// Bad: updating searchQuery re-renders everything including the heavy table
function Page() {
  const [searchQuery, setSearchQuery] = useState('');
  const [data, setData] = useState([]);

  return (
    <div>
      <SearchInput value={searchQuery} onChange={setSearchQuery} />
      <HeavyTable data={data} />
    </div>
  );
}

// Better: move searchQuery state into SearchInput
function Page() {
  const [data, setData] = useState([]);

  return (
    <div>
      <SearchInput onSearch={handleSearch} />
      <HeavyTable data={data} />
    </div>
  );
}

function SearchInput({ onSearch }) {
  const [query, setQuery] = useState('');

  return (
    <input
      value={query}
      onChange={e => {
        setQuery(e.target.value);
        onSearch(e.target.value);
      }}
    />
  );
}

By moving searchQuery state down into SearchInput, typing no longer re-renders HeavyTable. This is called “state colocation” and it is often more effective than memoization.

A Decision Framework

Follow this checklist when you suspect a performance issue:

  1. Profile first. Use React DevTools to confirm there is a real problem.
  2. Try state colocation. Move state closer to where it is used.
  3. Use React.memo on components that re-render often with the same props.
  4. Use useCallback on functions passed to memoized children.
  5. Use useMemo for expensive computations or stable object/array references.
  6. Consider virtualization for long lists.

Wrapping Up

React’s memoization tools are powerful but should be applied surgically. Profile your app to find actual bottlenecks, try restructuring state first, and reach for React.memo, useMemo, and useCallback only when you have evidence they will help. Remember that every optimization adds complexity and has a memory cost. The fastest code is often the simplest code that React can render efficiently on its own.