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.current is 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.current during 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.

Letting the React Compiler do it

React Compiler reached 1.0 in October 2025. It memoizes your code for you: at build time it analyzes each component and inserts the equivalent of useMemo, useCallback, and memo where they actually help. You write plain code and stop hand-placing dependency arrays.

It is still opt-in rather than on by default. In Next.js you install the Babel plugin and flip one config flag:

next.config.ts
// pnpm add -D babel-plugin-react-compiler
import type { NextConfig } from "next";

const nextConfig: NextConfig = {
  reactCompiler: true,
};

export default nextConfig;

Next.js narrows the work with an SWC pass that only hands the compiler files containing JSX or hooks, so builds stay close to normal speed. The compiler-powered lint rules ship in eslint-plugin-react-hooks, which will tell you when a component breaks the rules it depends on.

The compiler needs pure components
Keep render pure: no mutating props or state, no side effects during render. Where a component breaks those rules the compiler bails out on that component rather than miscompiling it, so you lose the optimization silently. The lint rules are how you find those spots.

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.