useRef, useMemo, and useCallback

Escaping the render cycle, and memoising only when it actually pays for itself.

What you'll learn

  • What a ref is, and the two things it is used for
  • Caching an expensive calculation with useMemo
  • Keeping a function's identity stable with useCallback
  • Why memoising everything makes code slower to read and no faster to run

useRef: a value that survives renders

A ref is a box whose .current you can read and write. Two properties make it useful:

  • It persists across renders, like state.
  • Changing it does not re-render, unlike state.

That combination is for values the UI does not display. Compare a ref with a plain variable and with state:

import { useRef, useState } from "react";

export default function App() {
  const [renders, setRenders] = useState(0);
  const clicks = useRef(0);
  let plain = 0;

  return (
    <div style={{ fontFamily: "sans-serif", padding: 16, fontSize: 13 }}>
      <button onClick={() => { clicks.current++; plain++; }}>
        Click me (no re-render)
      </button>
      <button onClick={() => setRenders(renders + 1)} style={{ marginLeft: 8 }}>
        Force a re-render
      </button>

      <ul>
        <li>ref: {clicks.current} — counted every click, shown when something else renders</li>
        <li>plain variable: {plain} — reset to 0 on every render</li>
        <li>renders: {renders}</li>
      </ul>
    </div>
  );
}

Click the first button several times, then force a re-render: the ref shows the true total, while the plain variable is back to zero. The ref remembered; it just did not ask for a render.

Never read or write a ref during rendering — that makes the render impure. Refs belong in event handlers and effects.

The other use: reaching a DOM node

Pass a ref to a JSX element and React puts the DOM node in .current:

import { useRef } from "react";

export default function App() {
  const inputRef = useRef(null);

  return (
    <div style={{ fontFamily: "sans-serif", padding: 16 }}>
      <input ref={inputRef} placeholder="Click the button" />
      <button onClick={() => inputRef.current.focus()} style={{ marginLeft: 8 }}>
        Focus the input
      </button>
    </div>
  );
}

Focusing, measuring, scrolling, and playing media are the legitimate cases. It is not a way to set values by hand — that is still state's job.

useMemo: cache a calculation

Every render re-runs the whole component body. Usually that is cheap. When it is genuinely not, useMemo caches the result and only recomputes when its dependencies change.

Imagine the function below is expensive — sorting thousands of rows, say. It logs whenever it actually runs. Type in the box: nothing is logged, because the memo returns the cached result. Click the number button and it recalculates.

import { useMemo, useState } from "react";

function expensiveTotal(n) {
  console.log("recalculated for " + n);
  return n * 2;
}

export default function App() {
  const [number, setNumber] = useState(1);
  const [text, setText] = useState("");

  // Only re-runs when `number` changes, not when `text` does.
  const total = useMemo(() => expensiveTotal(number), [number]);

  return (
    <div style={{ fontFamily: "sans-serif", padding: 16, fontSize: 13 }}>
      <button onClick={() => setNumber(number + 1)}>number is {number}</button>
      <input value={text} onChange={(e) => setText(e.target.value)} placeholder="Type here" style={{ marginLeft: 8 }} />
      <p>total: {total}</p>
    </div>
  );
}

Remove the useMemo and every keystroke recalculates. When the calculation is genuinely slow, that is the difference between a responsive input and a janky one — and it is a rarer situation than people assume.

useCallback: cache a function

Every render creates new function objects. Usually harmless — but it matters when a function is a dependency of an effect, or a prop to a memoised child, because identity comparison then sees a change every render.

useCallback returns the same function instance until its dependencies change. It only pays off with React.memo, which skips re-rendering a child whose props are unchanged:

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

const Child = memo(function Child({ onAction, label }) {
  console.log("rendered:", label);
  return <button onClick={onAction}>{label}</button>;
});

export default function App() {
  const [count, setCount] = useState(0);

  // New function every render — Child re-renders even though nothing changed.
  const unstable = () => console.log("unstable clicked");

  // Same function across renders — Child is skipped.
  const stable = useCallback(() => console.log("stable clicked"), []);

  return (
    <div style={{ fontFamily: "sans-serif", padding: 16 }}>
      <button onClick={() => setCount(count + 1)}>re-render parent ({count})</button>
      <div style={{ marginTop: 8, display: "flex", gap: 8 }}>
        <Child onAction={unstable} label="unstable" />
        <Child onAction={stable} label="stable" />
      </div>
    </div>
  );
}

Click "re-render parent" and watch the console: only unstable logs again. memo compared the props, found a brand-new function, and re-rendered.

useMemo caches a value; useCallback caches a function. They are the same idea — useCallback(fn, deps) is useMemo(() => fn, deps).

When not to memoise

Memoising is not free. It costs a dependency array to keep correct, it costs readability, and React still has to compare the dependencies on every render.

Reach for it when you have measured a problem, or when a value is a dependency of something that compares by identity. Otherwise leave it out. Re-rendering is usually fast, and premature memoisation adds bugs — a stale dependency array gives you a value that never updates, which is worse than a slow one.

Common mistakes

  • Reading ref.current during render. It makes render impure and will not update the screen anyway.
  • Using a ref instead of state. If the UI shows it, it is state.
  • useCallback without memo. A stable function passed to a normal child changes nothing.
  • Memoising everything. More code, more dependencies to get wrong, no measured gain.

Exercise

The timer below cannot be stopped, because the interval id is stored in a plain variable that resets on every render. Move it into a ref so "Stop" works.

import { useState } from "react";

export default function App() {
  const [seconds, setSeconds] = useState(0);
  let intervalId = null;

  function start() {
    intervalId = setInterval(() => setSeconds((s) => s + 1), 500);
  }

  function stop() {
    clearInterval(intervalId);
  }

  return (
    <div style={{ fontFamily: "sans-serif", padding: 16 }}>
      <p>{seconds}</p>
      <button onClick={start}>Start</button>
      <button onClick={stop} style={{ marginLeft: 8 }}>Stop</button>
    </div>
  );
}