useMemo and useCallback in React: When You Really Need Them

1. Overview

useMemo and useCallback are often used intuitively: either you wrap everything “to be safe”, or you never use them. Both approaches are wrong. This article explains the mechanisms behind these two hooks, points out three situations where they’re truly useful, and cases where you should skip them. You’ll also write a test to prove memoization actually works.

2. The Root Problem: Every Render Creates Everything Anew

Every time a component re-renders, the entire function body runs again from scratch. Every object, array, and function declared inside are created fresh.

function Checkout({ customer }) {
  const shipping = { city: customer.city, express: true };
  const handlePay = () => console.log('Pay for', customer.name);

  return <PaymentForm shipping={shipping} onPay={handlePay} />;
}

After each Checkout re-render, shipping and handlePay are new references, even though their content hasn’t changed. JavaScript compares objects and functions by reference:

{ city: 'Hue' } === { city: 'Hue' }   // false
(() => 1) === (() => 1)               // false

Usually this is harmless, because creating objects is cheap. It only becomes a problem when something compares by reference. That could be React.memo, the dependency array of useEffect, or another hook. useMemo and useCallback exist to keep references stable between renders.

3. useMemo: Memoizing a Value

useMemo(calculate, deps) calls calculate on the first render and stores the result. On subsequent renders, React only recalculates if an element in deps changes, compared using Object.is.

3.1. Avoid Expensive Calculations on Every Render

import { useMemo, useState } from 'react';

function OrderReport({ orders }) {
  const [keyword, setKeyword] = useState('');
  const [darkMode, setDarkMode] = useState(false);

  const visibleOrders = useMemo(() => {
    const kw = keyword.toLowerCase();
    return orders
      .filter((o) => o.customer.toLowerCase().includes(kw))
      .sort((a, b) => b.total - a.total);
  }, [orders, keyword]);

  return (
    <div className={darkMode ? 'dark' : ''}>
      <input value={keyword} onChange={(e) => setKeyword(e.target.value)} />
      <button onClick={() => setDarkMode((d) => !d)}>Toggle theme</button>
      <ul>
        {visibleOrders.map((o) => (
          <li key={o.id}>{o.customer}: {o.total}</li>
        ))}
      </ul>
    </div>
  );
}

Explanation:

  • The filter and sort operations only run again when orders or keyword changes.
  • When you click “Toggle theme”, darkMode changes and the component re-renders. At this point useMemo returns the stored array without filtering again.
  • sort is safe here because filter already returns a new array. Nothing is mutating the orders prop directly.

How do you know if a calculation is “expensive”? Measure instead of guessing:

console.time('filter orders');
const result = orders.filter((o) => o.customer.includes(keyword));
console.timeEnd('filter orders');
filter orders: 0.08ms

A practical threshold: if a calculation takes less than about 1ms on your dev machine, memoization is hardly necessary. Remember to measure in a production build, and even better, enable CPU throttling in DevTools, since users’ machines are usually slower than yours.

3.2. Keep an Object Stable for Passing Down

The second case isn’t about calculation speed. Here you need an object with a stable reference to use as a dependency:

function CustomerOrders({ customerId, status }) {
  const [orders, setOrders] = useState([]);

  const query = useMemo(() => ({ customerId, status }), [customerId, status]);

  useEffect(() => {
    fetchOrders(query).then(setOrders);
  }, [query]);

  return <OrderTable orders={orders} />;
}

Without useMemo, query would be a new object after every render. The effect would then run continuously, and because setOrders triggers another render, you could end up in an infinite API call loop.

Note: In this example there’s actually a simpler approach. You create the object inside the effect and put primitive values in the dependency: useEffect(() => { fetchOrders({ customerId, status }).then(setOrders); }, [customerId, status]);. Prefer that approach. Only use useMemo when the object must live outside the effect, for example when it’s passed to a child component.

4. useCallback: Memoizing a Function

useCallback(fn, deps) is equivalent to useMemo(() => fn, deps). It returns the exact function you pass in and keeps the same reference until deps changes. useCallback doesn’t make functions run faster. This hook only matters when the function is compared somewhere.

4.1. Combining with React.memo

React.memo skips re-rendering a child component if props are the same as last time (shallow comparison). If a prop is a function created fresh each time, memo loses its effect.

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

const OrderRow = memo(function OrderRow({ order, onSelect, onRender }) {
  onRender?.(order.id);
  return <li onClick={() => onSelect(order.id)}>{order.customer}</li>;
});

export function OrderList({ orders, onRender }) {
  const [selectedId, setSelectedId] = useState(null);
  const [note, setNote] = useState('');

  const handleSelect = useCallback((id) => setSelectedId(id), []);

  return (
    <>
      <input aria-label="note" value={note} onChange={(e) => setNote(e.target.value)} />
      <ul>
        {orders.map((o) => (
          <OrderRow key={o.id} order={o} onSelect={handleSelect} onRender={onRender} />
        ))}
      </ul>
      <p>Selected: {selectedId ?? 'none'}</p>
    </>
  );
}

Explanation:

  • handleSelect has an empty dependency array because setSelectedId is always stable (React guarantees this for useState setters).
  • When you type in the note field, OrderList re-renders. However, each OrderRow receives the same order, same handleSelect, same onRender, so memo skips them.
  • If you changed handleSelect to a regular arrow function, every row would re-render every time you type.

Key takeaway: useCallback is nearly useless without memo on the receiving end. Passing a memoized callback to a DOM <button> saves nothing.

4.2. Function Used as a Dependency

function useOrderPolling(customerId) {
  const [orders, setOrders] = useState([]);

  const refresh = useCallback(async () => {
    const data = await fetchOrders({ customerId });
    setOrders(data);
  }, [customerId]);

  useEffect(() => {
    refresh();
    const id = setInterval(refresh, 10_000);
    return () => clearInterval(id);
  }, [refresh]);

  return { orders, refresh };
}

refresh is both used inside the effect and returned to the outside component. Thanks to useCallback, the interval is only recreated when customerId changes. Any component using refresh as a dependency won’t unnecessarily re-run.

This is a rule worth keeping when writing custom hooks: functions returned to the outside should be wrapped in useCallback. You don’t know in advance whether a hook user will put it in a dependency or pass it to memo.

5. Verifying with Tests

Instead of trusting that memoization “probably works”, you can write a test for it. Install Vitest and React Testing Library:

npm install -D vitest jsdom @testing-library/react

Configure the jsdom environment in vite.config.js:

export default {
  test: { environment: 'jsdom' },
};

Test for OrderList from section 4.1:

import { render, screen, fireEvent } from '@testing-library/react';
import { describe, it, expect, vi } from 'vitest';
import { OrderList } from './OrderList';

const orders = [
  { id: 1, customer: 'Alice' },
  { id: 2, customer: 'Bob' },
];

describe('OrderList', () => {
  it('does not re-render OrderRow when only typing a note', () => {
    const onRender = vi.fn();
    render(<OrderList orders={orders} onRender={onRender} />);
    expect(onRender).toHaveBeenCalledTimes(2);

    fireEvent.change(screen.getByLabelText('note'), { target: { value: 'urgent delivery' } });

    expect(onRender).toHaveBeenCalledTimes(2);
  });

  it('still updates when selecting an order', () => {
    render(<OrderList orders={orders} />);
    fireEvent.click(screen.getByText('Bob'));
    expect(screen.getByText('Selected: 2')).toBeTruthy();
  });
});
 ✓ OrderList > does not re-render OrderRow when only typing a note
 ✓ OrderList > still updates when selecting an order

Try removing useCallback and running again. The first test will fail: instead of 2 calls you’ll see 4. That’s clear evidence the hook is doing its job. Declare onRender outside and pass the same reference, otherwise this prop itself will break memo.

Beyond tests, you should use the Profiler tab in React DevTools with “Highlight updates” enabled to visually see which components re-render.

6. When NOT to Use

Memoization isn’t free. React must store the old value, store the dependency array, and compare them every render. Code also becomes harder to read. Skip it in these cases:

  • Cheap calculations: adding a few numbers, concatenating strings, map over a few dozen items.
  • Primitive values: useMemo(() => a + b, [a, b]) returns a number. Numbers are compared by value anyway, so memoization helps nothing.
  • Callbacks passed to DOM elements or to child components not wrapped in memo.
  • Child component still receives changing props every time. For example <OrderRow order={{ ...o }} /> or passing children as JSX. JSX is also a new object every render, so memo will always see a difference.
  • You can restructure instead of memoizing. Moving state closer to where it’s used, or passing heavy components through children, usually eliminates unnecessary renders more completely.

Note: Don’t rely on useMemo to guarantee correct logic. React can delete the cache, for example in some scenarios with Suspense or in the future. Your code must work correctly even without memoization. This hook is just a performance optimization. Likewise, don’t put side effects like API calls or localStorage writes inside useMemo. They belong in useEffect or event handlers.

The most common mistake is missing dependencies, leading to stale closure:

const handleSave = useCallback(() => {
  saveOrder({ ...order, note });
}, [order]); // missing `note`

handleSave will always use the note value from the first creation. Enable the react-hooks/exhaustive-deps rule from eslint-plugin-react-hooks and don’t ignore its warnings.

7. How React Compiler Changes Things

React Compiler, stable since late 2025, analyzes components at build time and automatically inserts memoization at a more granular level than you’d typically write by hand. To try it in a Vite or Babel project:

npm install -D babel-plugin-react-compiler

With the compiler enabled, you barely need to write useMemo, useCallback, or memo anymore. The old hooks still work. They’re useful when you need precise control over an effect’s dependency. The compiler can only optimize code following React’s rules: pure components, not mutating props or state directly. That’s why understanding these hooks remains necessary for reading old code and debugging when the compiler misses a component.

8. Conclusion

useMemo and useCallback solve the same problem: keeping references stable between renders. They’re truly worth using in three situations:

  1. A calculation is measurably expensive.
  2. A value or function is passed to a component wrapped in React.memo.
  3. A value or function lives in the dependency array of useEffect or another hook, including functions returned from custom hooks.

Outside those cases, keep your code simple. Measure with the Profiler, prove it with tests, and consider enabling React Compiler to let the tool handle this work instead.

reacthooksusememousecallbackperformance