Skip to solution
hardFrontend

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

176 views
01

Understand 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.

reactreact-19refs
02

Attempt it yourself

Sketch your approach before reading the solution — that's what interviews test.

Nudge consolestandby

Stuck? Beam a request up — the console returns a conceptual nudge that guides your logic without spoiling the implementation.

03

Study the solution

Target Audience: Engineers preparing for React interviews — assumes refs and forwardRef. Difficulty: Hard

How to read this doc: Concepts are explained in plain language first, then tagged with 📌 Interview term:. Every claim below — including whether forwardRef warn

Solution ready — 2 min read

Classified // press E to declassify

04

Explore the playground snippets

ref as a plain prop, and a ref callback that cleans up after itself
Run Playground
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>
  );
}
05

Join the discussion

Discussion (0)

Sign in to join the discussion.

No responses yet. Be the first to share what you think.

Transmission complete // awaiting log

KEEP THE
STREAK ALIVE.

Dossier 114 of 119 decoded in the React.js track. One more won't hurt.

Back to track