React useMemo and useCallback: When to Use Them

Key takeaways

useMemo and useCallback solve two specific problems: expensive recomputation and unstable references that break React.memo, effect dependencies, or Context consumers. This guide covers when each case applies, the dependency-array bugs that make memoization wrong, and how to confirm the benefit with the React DevTools Profiler.

Introduction

Function components re-execute on every render: when their state changes, when a parent re-renders, or when a context they read changes. For most components that is fine, because a render is usually well under a millisecond. useMemo and useCallback let a component reuse a previous result instead of recreating it, and they address exactly two problems:

  1. A computation inside the component is genuinely expensive (filtering or sorting thousands of items, heavy math), and it reruns on renders that did not change its inputs.
  2. A new object or function reference created on every render is passed somewhere that compares by reference: a React.memo child, a useEffect dependency array, a Context value, or a library hook. The new reference makes React think something changed.

They are not a general-purpose speed-up, and applying them everywhere adds memory, comparison work, and dependency arrays that have to be kept correct. The rest of this article is about recognizing the two cases, getting the dependencies right, and verifying the result.


Concepts: re-renders and reference equality

  • Re-render: when a parent renders, its children render too by default, whether or not their props changed. React.memo opts a child out of that when its props are shallowly equal to last time.
  • Reference equality: object, array, and function literals are new references every render. {a: 1} === {a: 1} is false. So any mechanism that compares with Object.is (memo’s shallow prop check, effect dependencies, Context value comparison) sees a “change” even when the contents are identical.
  • useMemo: useMemo(() => compute(a, b), [a, b]) calls compute on the first render and then only when a or b changes, returning the cached result otherwise.
  • useCallback: useCallback(fn, [a]) returns the same function object until a changes. It is shorthand for useMemo(() => fn, [a]). It does not make the function run faster; it only keeps its identity stable.

One detail from the React docs worth knowing: the cache is a performance hint, not a semantic guarantee. React may discard cached values (for example, during development double-rendering or in future features), so code must stay correct if the computation reruns.


useMemo for expensive values

Filtering a large list

import { useState } from "react";

type Item = { id: string; label: string; score: number };

export function LeaderboardBad({ items }: { items: Item[] }) {
  const [query, setQuery] = useState("");
  const q = query.trim().toLowerCase();
  // Runs on every render, including renders caused by unrelated state
  const visible = !q ? items : items.filter((it) => it.label.toLowerCase().includes(q));

  return (
    <section>
      <input value={query} onChange={(e) => setQuery(e.target.value)} />
      <ul>
        {visible.map((it) => (
          <li key={it.id}>{it.label} — {it.score}</li>
        ))}
      </ul>
    </section>
  );
}

With a few hundred items this is fine. With tens of thousands, and a component that also re-renders for other reasons, the filter becomes visible in the Profiler.

Chaining memoized steps

Splitting the work into separate useMemo calls lets each step rerun only when its own inputs change:

import { useMemo, useState } from "react";

type Item = { id: string; label: string; score: number };

export function Leaderboard({ items }: { items: Item[] }) {
  const [query, setQuery] = useState("");
  const [sortDir, setSortDir] = useState<"asc" | "desc">("desc");

  // Reruns only when items or query change
  const filtered = useMemo(() => {
    const q = query.trim().toLowerCase();
    if (!q) return items;
    return items.filter((it) => it.label.toLowerCase().includes(q));
  }, [items, query]);

  // Reruns only when filtered or sortDir change
  const sorted = useMemo(
    () => [...filtered].sort((a, b) => (sortDir === "desc" ? b.score - a.score : a.score - b.score)),
    [filtered, sortDir]
  );

  return (
    <section>
      <input value={query} onChange={(e) => setQuery(e.target.value)} placeholder="Search..." />
      <button onClick={() => setSortDir((d) => (d === "asc" ? "desc" : "asc"))}>
        Sort {sortDir === "desc" ? "↑" : "↓"}
      </button>
      <ul>
        {sorted.map((it) => (
          <li key={it.id}>{it.label} — {it.score}</li>
        ))}
      </ul>
    </section>
  );
}

Toggling the sort direction reruns only the sort; typing reruns both. Note the [...filtered] copy: Array.prototype.sort mutates in place, and sorting the cached filtered array (or the items prop itself when the query is empty) would corrupt data that other code still holds.

The benefit depends on items being referentially stable. If the parent does <Leaderboard items={data.filter(...)} />, items is a new array on every parent render, the dependency changes every time, and the memo never hits. Memoization is only as good as the stability of its inputs, which is why it often has to be applied one level up as well.


useCallback for stable function references

The problem: memo children with inline callbacks

import { memo, useState } from "react";

const Row = memo(function Row({ id, onSelect }: { id: string; onSelect: (id: string) => void }) {
  console.log(`Row ${id} render`);
  return <button onClick={() => onSelect(id)}>{id}</button>;
});

export function TableBad({ ids }: { ids: string[] }) {
  const [active, setActive] = useState<string | null>(null);
  const handleSelect = (id: string) => setActive(id); // new function every render

  return (
    <div>
      <p>active: {active}</p>
      {ids.map((id) => (
        <Row key={id} id={id} onSelect={handleSelect} />
      ))}
    </div>
  );
}

Row is wrapped in memo, yet every row re-renders on every click, because onSelect is a different function each time and memo’s shallow comparison fails.

The fix

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

const Row = memo(function Row({ id, onSelect }: { id: string; onSelect: (id: string) => void }) {
  console.log(`Row ${id} render`);
  return <button onClick={() => onSelect(id)}>{id}</button>;
});

export function Table({ ids }: { ids: string[] }) {
  const [active, setActive] = useState<string | null>(null);
  const [count, setCount] = useState(0);

  // setActive is stable, so no dependencies are needed
  const handleSelect = useCallback((id: string) => setActive(id), []);

  return (
    <div>
      <p>active: {active}</p>
      <button onClick={() => setCount((c) => c + 1)}>Increment ({count})</button>
      {ids.map((id) => (
        <Row key={id} id={id} onSelect={handleSelect} />
      ))}
    </div>
  );
}

Now clicking a row or the Increment button re-renders Table, but no Row re-renders, because id and onSelect are unchanged for every row. (If Row also received isActive={id === active}, only the two rows whose isActive flipped would re-render.)

useCallback alone does nothing here without memo on Row. The two only work as a pair.

Functional updates keep callbacks dependency-free

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

type Todo = { id: string; text: string; done: boolean };

const TodoItem = memo(function TodoItem({
  todo,
  onToggle,
}: {
  todo: Todo;
  onToggle: (id: string) => void;
}) {
  return (
    <li>
      <input type="checkbox" checked={todo.done} onChange={() => onToggle(todo.id)} />
      {todo.text}
    </li>
  );
});

export function TodoList() {
  const [todos, setTodos] = useState<Todo[]>([
    { id: "1", text: "Learn React", done: false },
    { id: "2", text: "Build app", done: false },
  ]);

  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} onToggle={handleToggle} />
      ))}
    </ul>
  );
}

Because the updater reads prev instead of the todos variable from the closure, handleToggle does not depend on todos and never needs to be recreated. And because the map keeps untouched todo objects as the same references, only the toggled item re-renders.


Stable objects: effects and Context values

Objects in effect dependencies

import { useEffect } from "react";

export function ConfigBad({ userId }: { userId: string }) {
  const config = { userId, theme: "dark" };
  useEffect(() => {
    // fetch settings with config...
  }, [config]); // new object every render, so the effect runs every render
  return <div>User: {userId}</div>;
}

Two fixes, in order of preference:

import { useEffect, useMemo } from "react";

// 1. Depend on the primitive the effect actually uses
export function Config({ userId }: { userId: string }) {
  useEffect(() => {
    const config = { userId, theme: "dark" };
    // fetch settings with config...
  }, [userId]);
  return <div>User: {userId}</div>;
}

// 2. If the object must be shared (e.g. passed to a child and an effect), memoize it
export function ConfigShared({ userId }: { userId: string }) {
  const config = useMemo(() => ({ userId, theme: "dark" }), [userId]);
  useEffect(() => {
    // fetch settings with config...
  }, [config]);
  return <div>User: {userId}</div>;
}

Moving the object inside the effect is simpler and cannot go stale. Reach for useMemo only when the same object genuinely has to exist outside the effect.

Context provider values

This is the case I find most often in real codebases, and it is easy to miss because nothing looks wrong in the provider itself:

// BAD: new object every render, so every consumer re-renders whenever AppProvider renders
function AppProvider({ children }: { children: React.ReactNode }) {
  const [user, setUser] = useState<User | null>(null);
  const [theme, setTheme] = useState<"light" | "dark">("light");

  return (
    <AppContext.Provider value={{ user, setUser, theme, setTheme }}>
      {children}
    </AppContext.Provider>
  );
}

// GOOD: the value only changes when user or theme changes
function AppProvider({ children }: { children: React.ReactNode }) {
  const [user, setUser] = useState<User | null>(null);
  const [theme, setTheme] = useState<"light" | "dark">("light");

  const value = useMemo(() => ({ user, setUser, theme, setTheme }), [user, theme]);

  return <AppContext.Provider value={value}>{children}</AppContext.Provider>;
}

React compares the provider value with Object.is. An inline object is always “different”, so every component calling useContext(AppContext) re-renders whenever the provider’s parent renders, and React.memo on those consumers does not help because context bypasses the props check. setUser and setTheme come from useState and are stable, so they do not need to be in the dependency list.

Memoizing the value still re-renders every consumer when either field changes. If a theme toggle should not re-render components that only care about the user, split the context by rate of change:

const AuthContext = createContext<AuthState | null>(null); // changes rarely
const ThemeContext = createContext<ThemeState | null>(null); // changes on toggle

// Components that only read ThemeContext do not re-render on login/logout, and vice versa

The first time I profiled an app with a single large context, typing into one form field re-rendered the navigation bar, the sidebar, and every card on the page, because the form state lived in the same provider object. Wrapping the value in useMemo did not fix it (the value really was changing on every keystroke); splitting the form state into its own context did.


Dependency-array pitfalls

Stale closures

A missing dependency means the memoized function keeps the value from the render it was created in:

// BUG: userId is used but not listed, so this always fetches the first user
const fetchUser = useCallback(async () => {
  const data = await api.get(`/users/${userId}`);
  setUser(data);
}, []);

// CORRECT
const fetchUser = useCallback(async () => {
  const data = await api.get(`/users/${userId}`);
  setUser(data);
}, [userId]);

The stale version usually works in a quick manual test, because the first value is the one you test with, and only breaks after navigation or a prop change. Enable react-hooks/exhaustive-deps from eslint-plugin-react-hooks and treat its warnings as errors. When a callback only updates state, a functional update (setCount((c) => c + 1)) removes the dependency entirely instead of suppressing the warning.

Objects and arrays as dependencies

Dependencies are compared by reference, so an object created in the parent on every render defeats the memo:

// BAD: options is a new object on every parent render, so this reruns every time
const results = useMemo(() => filterData(data, options), [data, options]);

// GOOD: depend on the primitive fields you actually use
const { limit, offset } = options;
const results = useMemo(() => filterData(data, { limit, offset }), [data, limit, offset]);

A useMemo whose dependencies change on every render is strictly worse than no useMemo: it does the work every time and pays for the comparison and cache.

Values that should not trigger updates

For mutable values that callbacks need to read but that should not recreate the callback, such as a timer handle, use a ref:

const timeoutRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);

const handleDebounce = useCallback(() => {
  clearTimeout(timeoutRef.current);
  timeoutRef.current = setTimeout(() => {
    // do something
  }, 300);
}, []); // refs are stable, so no dependencies needed

Measuring with the Profiler

Memoization decisions should come from measurement, not intuition. With the React DevTools browser extension:

  1. Open DevTools, go to the Profiler tab, and enable “Record why each component rendered while profiling” in its settings.
  2. Click Record, perform the interaction that feels slow (typing, toggling, scrolling), then Stop.
  3. In the flame graph, wide and warm-colored bars are components that took long to render; grey ones did not render in that commit.
  4. Select a component to see why it rendered: “Props changed: onSelect” on a memoized child is the signature of an unstable callback; “Context changed” points at a provider value; “The parent component rendered” on a child that is not memoized means useCallback on its props would do nothing yet.
  5. Apply one change, record the same interaction again, and compare commit durations.

Also profile a production build when the numbers matter. Development builds are noticeably slower and add checks (like Strict Mode double-rendering) that distort render times.

In my experience the results follow a consistent pattern: most components render fast enough that memoizing them changes nothing measurable, while a handful (long lists, charts, large forms, and consumers of a busy context) account for most of the cost. Those few are where useMemo, useCallback, and memo pay off; for long lists, virtualization often beats any amount of memoization.


When not to memoize

  • Cheap computations: arithmetic, string concatenation, a slice or map over a short array. useMemo(() => a + b, [a, b]) is slower and harder to read than a + b.
  • Children that are not memoized: a stable callback passed to a plain component changes nothing, because that child re-renders with its parent regardless.
  • Dependencies that change every render: the cache never hits.
  • Values used only in the JSX of the same component: there is no reference comparison to satisfy.
// No benefit: Child is not memoized, so it re-renders with Parent anyway
function Parent() {
  const fn = useCallback(() => doSomething(), []);
  return <Child onAction={fn} />;
}

// Benefit: Child is memoized, so the stable reference skips its render
const Child = memo(function Child({ onAction }: { onAction: () => void }) {
  return <button onClick={onAction}>Click</button>;
});

Structural fixes often beat memoization entirely. Moving state down into the component that uses it, passing expensive subtrees as children so they are created by a parent that does not re-render, and moving data fetching to Server Components all shrink the amount of client work that needs memoizing in the first place. If your project uses the React Compiler, it inserts equivalent memoization automatically, and hand-written useMemo/useCallback becomes mostly redundant.


Conclusion

Key takeaways

  1. Measure first with the React DevTools Profiler, and look at why a component rendered, not just how long it took.
  2. useMemo for measurably expensive computations and for objects that must keep their identity (effect dependencies, Context values, memoized children).
  3. useCallback only when the function goes to a memo child, an effect dependency list, or an API that compares references.
  4. Keep dependencies complete, prefer functional updates, and depend on primitives rather than objects.
  5. Stabilize Context values with useMemo, and split contexts that change at different rates.

Decision guide

Should I use useMemo/useCallback?
├─ Is the computation measurably slow in the Profiler?
│  └─ Yes → useMemo
├─ Is the value/function passed to a React.memo child?
│  └─ Yes → useMemo / useCallback (and check the child's other props are stable too)
├─ Is it a Context provider value?
│  └─ Yes → useMemo (consider splitting the context)
├─ Is it in a useEffect dependency array?
│  └─ Yes → first try moving it inside the effect; otherwise memoize
└─ None of the above → don't memoize