C.W.K.
Stream
Lesson 04 of 07 · published

Refs in React 19 — Just a Prop Now

~13 min · react-19, ref, forwardref

Level 0React Novice
0 XP0/54 lessons0/12 achievements
0/100 XP to next level100 XP to go0% complete
React 19 retired forwardRef. Refs are now a normal prop. The wrapper that survived for nine years finally became unnecessary.

What changed

Before React 19, function components couldn't receive a ref directly — refs were special, attached by React's reconciler to DOM elements and class instances. To let a parent get a ref to your inner DOM node, you had to wrap the function in forwardRef(function MyInput(props, ref) { ... }). The wrapper made the second argument the ref.

React 19 made ref a regular prop. Type it, destructure it, forward it. The forwardRef helper still works (for compatibility) but new code shouldn't reach for it.

Three places refs matter

  1. DOM node access — focus an input, measure an element, scroll into view.
  2. Imperative APIs — third-party libraries that need you to call methods on an element.
  3. Storing mutable valuesuseRef({ current: ... }) as a render-stable mutable box. Lesson 3 of Track 3 covers this one.

This lesson is about the first two — refs that travel across the component boundary.

The new shape

The ref prop carries a React.Ref<T> where T is the underlying element type. Most of the time you just spread or assign it onto the inner element and you're done. If you need to expose imperative methods (an open() on a dialog, a focus() on a complex input), use useImperativeHandle with the ref.

The forwardRef migration

If you're maintaining a codebase that uses forwardRef, you don't have to migrate everything at once. Both styles work. The new code you write should use the prop directly; old code can stay until it's touched for other reasons.

Refs are an escape hatch. The first instinct when you reach for a ref should be 'is there a declarative way to do this?' Often yes — controlled inputs, CSS for scroll position, ARIA attributes for focus management. When the answer is genuinely no (third-party imperative APIs, native DOM measurement), refs are the right tool. Just don't reach first.

Code

Before / after — the same MyInput with ref·tsx
// BEFORE (React 18 and earlier — still works in 19 for compatibility)
import { forwardRef } from "react";

export const MyInput = forwardRef<HTMLInputElement, { label: string }>(
  function MyInput({ label }, ref) {
    return (
      <label className="flex flex-col gap-1">
        <span>{label}</span>
        <input ref={ref} className="border p-2 rounded" />
      </label>
    );
  }
);

// AFTER (React 19 — ref is just a prop)
type MyInputProps = {
  label: string;
  ref?: React.Ref<HTMLInputElement>;
};

export function MyInput({ label, ref }: MyInputProps) {
  return (
    <label className="flex flex-col gap-1">
      <span>{label}</span>
      <input ref={ref} className="border p-2 rounded" />
    </label>
  );
}

// Caller is identical in both cases:
// const inputRef = useRef<HTMLInputElement>(null);
// <MyInput label="Name" ref={inputRef} />
// inputRef.current?.focus();
useImperativeHandle — expose explicit methods·tsx
import { useImperativeHandle, useRef } from "react";

type DialogHandle = {
  open: () => void;
  close: () => void;
};

type DialogProps = {
  ref?: React.Ref<DialogHandle>;
  children: React.ReactNode;
};

export function Dialog({ ref, children }: DialogProps) {
  const dialogRef = useRef<HTMLDialogElement>(null);

  // Explicitly publish a small API instead of the raw DOM element.
  useImperativeHandle(
    ref,
    () => ({
      open: () => dialogRef.current?.showModal(),
      close: () => dialogRef.current?.close(),
    }),
    []
  );

  return (
    <dialog ref={dialogRef} className="rounded-card p-6">
      {children}
    </dialog>
  );
}

// Caller doesn't touch the DOM element at all:
// const dialogRef = useRef<DialogHandle>(null);
// <Dialog ref={dialogRef}>...</Dialog>
// dialogRef.current?.open();

External links

Exercise

Convert a forwardRef-wrapped component to the React 19 ref-as-prop style. Build a Modal component with useImperativeHandle that exposes open() and close() instead of the raw <dialog> element. Confirm a parent can call modalRef.current?.open() without touching the DOM. Bonus: try removing the useImperativeHandle and passing the dialog ref through directly — note how much more API surface the parent sees.
Hint
Native <dialog> element has showModal() and close() methods. The imperative handle wraps those so callers can't accidentally call other dialog methods.

Progress

Progress is local-only — sign in to sync across devices.
Spotted a bug or have feedback on this page?Report an Issue

Comments 0

🔔 Reply notifications (sign in)
Sign inPlease sign in to comment.

No comments yet — be the first.