Skip to the solution
HardFrontend6 min read

How did ref handling change in React 19 (ref as a prop, forwardRef, ref cleanup)?

176 views

The problem

Question presented to candidate: "React 19 changed how refs work. What changed, and what does it mean for existing code?"

What a strong answer should cover:

  • ref is now a regular prop for function components. You destructure it like any other; forwardRef is no longer needed for the common case.
  • forwardRef still works and was not removed — verified, it emits no deprecation warning in 19.2.8. Existing code keeps running.
  • Ref callbacks may return a cleanup function. React calls it on detach instead of invoking the callback a second time with null.
  • Why that matters: the old null-call pattern made it awkward to pair setup with teardown, and easy to leak an observer or listener attached in a ref callback.
  • The old behaviour is still supported for callbacks that return nothing, so existing ref callbacks are unaffected.
  • Practical effect: fewer wrapper layers, simpler TypeScript generics, and a component tree without ForwardRef(...) nodes.
  • Still true: ref on a function component only works if that component does something with it, and key remains a non-prop.
  • Migration: a codemod exists; there is no urgency since forwardRef still functions.

Clarifying questions expected:

  • "Are we on React 19 already, or planning the upgrade?"
  • "Is this a library that must support React 18 as well?" — that decides whether you can drop forwardRef.

Code / implementation expected: Yes — the same component before and after, plus a ref callback with a cleanup.

  • react
  • react-19
  • refs

Try it yourself

Sketch your approach before you read the solution. That is what the interview tests.

Stuck? Get a hint.

A short nudge toward the approach. It will not give the answer away.

Solution

The solution is hidden so you can try first

6 min read · 9 sections

Try it in the playground

Open any example in the playground to run and change it.

Reactref as a plain prop, and a ref callback that cleans up after itself
import { useRef, useState, useCallback, useImperativeHandle } from "react";

// ── React 19: ref is just a prop. No forwardRef wrapper. ──────────────────
function TextField({ ref, label, ...rest }) {
  return (
    <label style={{ display: "block", marginBottom: 8 }}>
      {label}: <input ref={ref} {...rest} />
    </label>
  );
}

// ── Still works: useImperativeHandle to expose a custom API rather than
//    the raw node. The ref still arrives as an ordinary prop. ──────────────
function Counter({ ref }) {
  const [n, setN] = useState(0);
  useImperativeHandle(ref, () => ({
    increment: () => setN((v) => v + 1),
    reset: () => setN(0),
  }), []);
  return <p style={{ margin: "8px 0" }}>Counter value: <strong>{n}</strong></p>;
}

// ── React 19: a ref CALLBACK may return a cleanup. Setup and teardown sit
//    together, exactly like an effect — no if (node === null) branch.
//
//    IMPORTANT: the callback must be STABLE. An inline arrow is a new function
//    every render, so React detaches and reattaches on each one — and if the
//    callback also sets state, that is an infinite loop. Hence useCallback.
function Measured({ onLog }) {
  const measureRef = useCallback((node) => {
    // ResizeObserver is a browser API; guard it so this also runs in a
    // non-browser environment such as a test renderer.
    const ro =
      typeof ResizeObserver !== "undefined"
        ? new ResizeObserver(() =>
            onLog("observed width " + Math.round(node.getBoundingClientRect().width)))
        : null;
    ro?.observe(node);
    onLog("attached observer");
    // Pre-19 this teardown had to live in a null branch of this same
    // callback, and was very easy to forget.
    return () => {
      ro?.disconnect();
      onLog("observer disconnected");
    };
  }, [onLog]);

  return (
    <div
      ref={measureRef}
      style={{ border: "2px dashed #4f46e5", padding: 12, borderRadius: 8, resize: "horizontal", overflow: "auto", minWidth: 160 }}
    >
      Drag my bottom-right corner to resize me.
    </div>
  );
}

export default function App() {
  const inputRef = useRef(null);
  const counterRef = useRef(null);
  const [mounted, setMounted] = useState(true);
  const [log, setLog] = useState([]);
  // Stable identity, so the ref callback below is also stable.
  const push = useCallback((line) => setLog((l) => [...l.slice(-5), line]), []);

  return (
    <div style={{ padding: 24, fontFamily: "system-ui", lineHeight: 1.6 }}>
      <TextField ref={inputRef} label="Name" placeholder="click focus below" />
      <button onClick={() => inputRef.current?.focus()}>focus the input</button>

      <hr />
      <Counter ref={counterRef} />
      <button onClick={() => counterRef.current?.increment()}>increment via ref</button>{" "}
      <button onClick={() => counterRef.current?.reset()}>reset via ref</button>

      <hr />
      {mounted && <Measured onLog={push} />}
      <p>
        <button onClick={() => setMounted((m) => !m)}>
          {mounted ? "unmount" : "mount"} the observed box
        </button>
      </p>
      <pre style={{ background: "#f6f6f6", padding: 10, borderRadius: 6, fontSize: 12 }}>
        {log.length ? log.join("\n") : "(resize or unmount the box)"}
      </pre>

      <p style={{ color: "#666", fontSize: 13 }}>
        Unmount the box and watch the log: the cleanup returned from the ref
        callback disconnects the observer. No forwardRef appears anywhere on
        this page.
      </p>
    </div>
  );
}

Discussion

Other approaches, follow-ups and how it went in real interviews.

Sign in to join the discussion.

No comments yet. Share how you would answer it.

Question 114 of 119 in React.js

Keep your momentum going.

Mark this question solved to track your progress, then take the next one while it is fresh.

All React.js questions
Up nextHard

What can and can't an Error Boundary catch?

Continue