Skip to main content
React Performance·Lesson 2 of 5

Memoization

React.memo

React.memo is a higher-order component that skips re-rendering when props haven't changed (shallow comparison by default).

import { memo } from "react";

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

const UserCard = memo(function UserCard({ name, email }: UserCardProps) {
  console.log("UserCard rendered");
  return (
    <div className="card">
      <h3>{name}</h3>
      <p>{email}</p>
    </div>
  );
});

// Parent re-renders won't cause UserCard to re-render
// unless name or email actually change
function UserList() {
  const [filter, setFilter] = useState("");
  return (
    <div>
      <input value={filter} onChange={(e)=> setFilter(e.target.value)} />
      <UserCard name="Alice" email="alice@example.com" />
    </div>
  );
}

Custom Comparison

You can provide a custom comparison function when shallow equality isn't enough:

const Chart = memo(
  function Chart({ data, config }: ChartProps) {
    return <canvas ref={renderChart(data, config)} />;
  },
  (prevProps, nextProps) => {
    // Only re-render if data length changed or config.type changed
    return (
      prevProps.data.length === nextProps.data.length &&
      prevProps.config.type === nextProps.config.type
    );
  }
);

useMemo

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

function ProductList({ products, filter }: ProductListProps) {
  // Without useMemo, this filters on EVERY render
  const filteredProducts = useMemo(() => {
    return products.filter((p) =>
      p.name.toLowerCase().includes(filter.toLowerCase())
    );
  }, [products, filter]);

  // Expensive sort — only recalculated when filteredProducts changes
  const sortedProducts = useMemo(() => {
    return [...filteredProducts].sort((a, b) => b.rating - a.rating);
  }, [filteredProducts]);

  return (
    <ul>
      {sortedProducts.map((p) => (
        <li key={p.id}>{p.name}{p.rating}</li>
      ))}
    </ul>
  );
}

Stabilizing Object References

useMemo is also useful for keeping object references stable so child memo components don't re-render:

function Dashboard({ userId }: { userId: string }) {
  const [refreshCount, setRefreshCount] = useState(0);

  // Without useMemo, a new object is created every render,
  // breaking React.memo on SettingsPanel
  const settings = useMemo(
    () => ({ theme: "dark", userId }),
    [userId]
  );

  return (
    <div>
      <button onClick={()=> setRefreshCount((c)=> c + 1)}>Refresh</button>
      <SettingsPanel settings={settings} />
    </div>
  );
}

const SettingsPanel = memo(function SettingsPanel({
  settings,
}: {
  settings: { theme: string; userId: string };
}) {
  console.log("SettingsPanel rendered");
  return <div>Theme: {settings.theme}</div>;
});

useCallback

useCallback memoizes a function reference. It is essentially useMemo for functions.

function TodoApp() {
  const [todos, setTodos] = useState<Todo[]>([]);

  // Without useCallback, a new function is created every render,
  // causing TodoItem (wrapped in memo) to re-render every time
  const handleDelete = useCallback((id: string) => {
    setTodos((prev) => prev.filter((t) => t.id !== id));
  }, []);

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

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

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

When NOT to Memoize

Memoization has a cost — memory for the cached value and comparison overhead on every render. Avoid it when:

  1. The component is cheap to render. Memoizing a <span> is slower than just re-rendering it.
  2. Props change on every render anyway. memo will run the comparison and re-render regardless.
  3. You're wrapping everything "just in case." Profile first, optimize second.
// BAD: Pointless memoization — primitive props change every render
function Counter() {
  const [count, setCount] = useState(0);

  // count changes every click, so useMemo does nothing useful
  const doubled = useMemo(() => count * 2, [count]);

  return <span>{doubled}</span>;
}

// GOOD: Just compute it inline — multiplication is trivial
function Counter() {
  const [count, setCount] = useState(0);
  return <span>{count * 2}</span>;
}

The Memoization Decision Checklist

Before reaching for memo, useMemo, or useCallback, ask:

  1. Is the component actually slow? Use React Profiler to measure.
  2. Can you lift state down or restructure to avoid the re-render entirely?
  3. Do the dependencies change frequently? If so, memoization won't help.
  4. Is the child wrapped in React.memo? useCallback only helps if the child checks prop equality.