webdev.complete
🧩 Advanced Hooks & Patterns
⚛️React
Lesson 84 of 117
25 min

Refs, memo, useCallback

When to memoize. When NOT to. The React Compiler future.

Two corners of React that confuse everyone: refs and memoization. Refs are the escape hatch for "I need direct access to this DOM node" or "I need to remember a value without re-rendering." Memoization (memo, useMemo, useCallback) is the optimization escape hatch. Both are easy to overuse. Let's learn when they actually pay off.

useRef for DOM access

Sometimes you need the actual HTML element. Focus an input, measure a div, scroll an element into view. Pass a ref to its ref attribute and React fills it in with the DOM node:

tsx
function SearchInput() {
  const inputRef = useRef(null);

  useEffect(() => {
    inputRef.current.focus();
  }, []);

  return <input ref={inputRef} />;
}

ref.currentis mutable. You can read and write it directly. Changes don't cause re-renders, which is the whole point. Refs are React's way of saying "this value exists, but don't make it part of the render cycle."

Refs as prop in React 19

Pre-React 19, you had to wrap your component in forwardRef to accept a ref from the outside. That's gone. Now ref is just a normal prop you can declare:

tsx
// React 18 and earlier
const MyInput = forwardRef(function MyInput(props, ref) {
  return <input ref={ref} {...props} />;
});

// React 19+
function MyInput({ ref, ...props }) {
  return <input ref={ref} {...props} />;
}

Simpler. Cleaner. forwardRef still works for compatibility, but new code should just accept ref as a prop.

useRef for mutable values

Refs aren't just for DOM. Anytime you need a value that survives renders but shouldn't trigger them, use a ref. Common cases:

  • An interval/timeout ID you need to clear later.
  • A WebSocket or other long-lived connection.
  • The previous value of a prop (for diffing).
  • A "has mounted" flag to suppress initial-render logic.
tsx
function Timer() {
  const intervalId = useRef(null);

  function start() {
    intervalId.current = setInterval(() => console.log("tick"), 1000);
  }
  function stop() {
    clearInterval(intervalId.current);
  }

  return <><button onClick={start}>Start</button><button onClick={stop}>Stop</button></>;
}
Don't read refs during render
Mutate refs inside event handlers, effects, or callbacks. Reading or writing ref.currentduring render makes your component impure and breaks React features like Suspense and concurrent rendering. Treat ref as "state that lives outside React."

React.memo: skip re-renders when props don't change

By default, when a parent re-renders, all its children re-render too, even if their props didn't change. memo wraps a component to skip the re-render when props are shallow-equal to the previous run.

tsx
const Row = memo(function Row({ item, onClick }) {
  console.log("rendering", item.id);
  return <li onClick={onClick}>{item.label}</li>;
});

Sounds great. The catch: if any prop is a fresh object, array, or function on every parent render (which is normal), the shallow compare fails and memo does nothing. You then need useCallback for the function and useMemo for the object/array.

useMemo and useCallback

tsx
// useMemo caches a value
const sorted = useMemo(() => list.sort(), [list]);

// useCallback caches a function (equivalent to useMemo(() => fn, [...]))
const onClick = useCallback(() => doThing(id), [id]);

Both take a function and a dependency array. They re-run only when a dep changes. Use cases:

  • An expensive computation (sorting thousands of items, parsing) you don't want repeated every render.
  • A function or object passed to a memoized child as a prop, so the child's shallow compare can succeed.
  • A value used as a dep in a downstream useEffect, so the effect doesn't fire on every render.
They are not free
useMemo and useCallback add their own bookkeeping cost (storing deps, comparing them, holding old values). For a trivial calculation or a function passed to a regular (non-memo) child, they make code slower, not faster. Measure before optimizing.

The React Compiler future

The React Compiler (shipped in 2025) memoizes your code automatically. When enabled, it analyzes your components at build time and inserts the equivalent of useMemo/useCallback/memo for you, only where it actually helps. The eventual end state: you write plain code, and React optimizes it.

For now, in 2026, the compiler is opt-in but stable in most Next.js and Vite setups. If you can enable it, do. You'll write less memoization noise. The rules: keep your components pure (no mutating props, no side effects during render) and the compiler does the rest.

Try it: a focus button and a memoized list

The first button uses a ref to focus the input. The list uses memo + useCallback to avoid re-rendering every row when only the counter changes. Open the console to watch the renders.

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

const Row = memo(function Row({ item, onSelect }) {
  console.log("[Row] rendering", item);
  return (
    <li onClick={() => onSelect(item)} style={{ cursor: "pointer", padding: 4 }}>
      {item}
    </li>
  );
});

export default function App() {
  const [count, setCount] = useState(0);
  const [selected, setSelected] = useState("");
  const items = ["alpha", "beta", "gamma", "delta"];
  const inputRef = useRef(null);

  // Stable across renders so memo(Row) can short-circuit
  const onSelect = useCallback((item) => setSelected(item), []);

  return (
    <div style={{ padding: 24, fontFamily: "system-ui" }}>
      <div style={{ marginBottom: 16 }}>
        <input ref={inputRef} placeholder="Click button to focus me" />{" "}
        <button onClick={() => inputRef.current.focus()}>Focus input</button>
      </div>

      <button onClick={() => setCount(count + 1)}>
        Re-render parent ({count})
      </button>
      <p>Selected: {selected || "none"}</p>

      <ul>
        {items.map((item) => <Row key={item} item={item} onSelect={onSelect} />)}
      </ul>

      <p style={{ fontSize: 12, color: "#6b7280" }}>
        Open console. Click &quot;Re-render parent.&quot; Rows don&apos;t re-render.
        Comment out useCallback and watch them all render again.
      </p>
    </div>
  );
}

Quiz

Quiz1 / 3

What changes when you write ref.current = 5?

Recap

  • useRef stores a value that survives renders without causing them. Use for DOM access and mutable non-render state.
  • React 19 made ref a regular prop. No more forwardRef needed.
  • React.memo skips re-renders when props are shallow equal. Often paired with useCallback/useMemo.
  • Memoization has overhead. Don't reach for it until you have a measured problem.
  • The React Compiler automates almost all of this. Enable it and write plain code.
Built with Next.js, Tailwind & Sandpack.
Learn. Build. Ship.