Skip to content
Chapter 22Lesson 4

Refs as a regular prop

How React 19 makes ref an ordinary prop you destructure and forward to a DOM node, retiring forwardRef.

Your <Input> renders a real <input> somewhere inside it, and sooner or later a parent needs to reach that node directly: to focus it when a form opens, scroll it into view after a validation error, or measure its width. None of that travels through props or state. It travels through a ref . The parent holds the ref; the problem is getting it onto the <input> buried inside your component.

This lesson answers one question: how does a ref cross the component boundary? You already pass onClick, className, and ...rest down to the inner element. In React 19, ref is just one more of those, an ordinary prop you destructure and pass along. Older code did this with a dedicated API, forwardRef, and a few extra lines per component; React 19 dropped it. You’ll still recognize forwardRef when you read it, without ever reaching for it yourself.

The whole model fits in one sentence: a function component accepts ref as an ordinary prop, you destructure it, and you hand it to the inner DOM element. Here’s the canonical <Input>:

const Input = ({ ref, ...props }: ComponentProps<'input'>) => (
<input ref={ref} {...props} />
);

You did not add a ref field to the props type. ComponentProps<'input'>, the element-props alias from the typed-props lesson, already includes ref, already typed against HTMLInputElement. It arrives free with the same alias that gives you value, onChange, and every other native attribute. Pull it out of the destructure, drop it on the <input>, and the forward is done.

A function Input({ ref, ...props }: ComponentProps<'input'>) declaration works identically, since ref is a real parameter either way, but stay with the arrow form so your components read the same.

On the parent side, useRef produces the ref:

const SearchBar = () => {
const inputRef = useRef<HTMLInputElement>(null);
return <Input ref={inputRef} placeholder="Search" />;
};

React assigns the rendered <input> to inputRef.current once it mounts, so the parent can later call inputRef.current?.focus(). For this lesson, treat useRef(null) as the thing that produces the ref you pass down; how .current stores a value and when it’s safe to read get their full treatment in the useRef lesson next chapter.

If ref is just a prop now, what did this look like before? Every codebase written before 2024, every shadcn component before mid-2025, and every pre-React-19 tutorial wrapped its components in forwardRef. You’ll read it sooner or later, so fix the before and after in your head:

const Input = forwardRef<HTMLInputElement, ComponentProps<'input'>>(
({ ...props }, ref) => <input ref={ref} {...props} />,
);
Input.displayName = 'Input';

The old ceremony. forwardRef wrapped the component so React could thread a second ref argument in beside props. You also paid for an explicit generic pair and a displayName to name the component in DevTools: six lines of boilerplate on every component that wanted a ref.

forwardRef still runs in React 19 but logs a deprecation warning, so you never write it yourself. When you inherit a codebase full of it, the official codemod npx codemod react/19/remove-forward-ref rewrites every call to the prop form, and ESLint flags any that creep back in. A forwardRef-heavy repo is a quick migration, not a rewrite.

Why did React remove it? ref used to be reserved by the runtime, like key: React intercepted it before it reached your component, and forwardRef existed only to pass the reserved prop back in. Making ref ordinary drops that special case, so the React Compiler in this course’s stack can reason about it like any other prop.

Typing a ref: Ref, RefObject, and RefCallback

Section titled “Typing a ref: Ref, RefObject, and RefCallback”

Most of the time you never write a ref type, because ComponentProps<'input'>['ref'] resolves it for you. You reach for the explicit type in one case: when your props type isn’t an element-props alias, so a ref type doesn’t come with it, yet the component should still accept a ref to its root node. Three type names cover everything you’ll meet.

type Ref<T> = RefObject<T> | RefCallback<T> | null;
type RefObject<T> = { current: T };
type RefCallback<T> = (node: T | null) => void;

Ref<T> is the one you’ll type, as ref?: Ref<HTMLDivElement> on hand-written props. Notice it’s a union of object, function, or null, not a single object type. Keep that in mind: it pays off when you merge two refs onto one node later in this lesson.

RefObject<T> is what useRef hands back, and its .current holds the value. In React 19 that .current is writable; the readonly-versus-writable split that once made MutableRefObject<T> a separate type is gone, so you only ever write RefObject and read MutableRefObject in pre-2025 code.

RefCallback<T> is the function form, (node: T | null) => void, the subject of the next section, where a ref runs code of yours instead of just passing the node through.

So: with an element-props alias the ref type comes for free; with hand-written props you annotate ref?: Ref<T>. The component below has hand-written props, not a ComponentProps<'tag'> alias, but should still accept a ref to its root <div>. Fill in the ref type.

This Dropdown's props are written by hand, so the ref type doesn't come for free. Pick the type that lets the ref prop accept anything an element's ref accepts. Pick the right option from each dropdown, then press Check.

type DropdownProps = {
items: string[];
ref?: ___;
};

The blank is Ref<HTMLDivElement>. An element’s ref prop accepts the full Ref<T> union, so a hand-written prop must declare that same union to match it. RefObject<HTMLDivElement> rejects callback refs, MutableRefObject<HTMLDivElement> is the deprecated pre-React-19 spelling you only ever read, and HTMLDivElement is the node itself, not a ref pointing at it.

Ref callbacks: running code when a node mounts

Section titled “Ref callbacks: running code when a node mounts”

A ref doesn’t have to be an object. Set ref to a function and React calls it with the DOM node the moment the element mounts, handing you the node directly instead of stashing it on .current:

<input ref={(node) => node?.focus()} />

Some work needs the node at the instant it attaches, not whenever you later read .current: starting an observer, measuring the element, or attaching a non-React event listener. A callback ref hands you the node at exactly that moment. The example this course returns to for lazy-loading images is wiring an IntersectionObserver to a node so you’re told when it scrolls into view:

<div ref={(node) => {
const observer = new IntersectionObserver(([entry]) => {
console.log(entry.isIntersecting ? 'in view' : 'out of view');
});
observer.observe(node);
}} />

Ignore the IntersectionObserver details for now. The point is that it needs the real <div> node to start watching, and the callback ref is what hands it over at the right moment.

One note on stability. An inline callback ref like the one above is a new function on every render, and React responds to a new function by running the old one with null, then the new one with the node. If the setup is expensive, wrapping the callback in useCallback gives React a stable function so it stops re-running. Why a new function triggers this is a render-model question the next chapter answers.

Wiring up an observer raises a question: who tears it down? An observer you create but never disconnect holds a live reference to a node that has left the page, which is a memory leak. React 19 gives a clean answer: a ref callback may return a cleanup function, and React runs it when the node unmounts.

<div ref={(node) => {
const observer = new IntersectionObserver(([entry]) => {
onVisible(entry.isIntersecting);
});
observer.observe(node);
return () => observer.disconnect();
}} />

Think of it as a mini-effect scoped to one DOM node: setup runs when the node attaches, the returned function runs when it detaches, and both live side by side in the same callback.

One behavioral detail matters. When you return a cleanup, React no longer calls the callback again with null on unmount; the returned function is the unmount signal now. So the lifecycle is setup then cleanup, two separate functions, with no null call:

<div> node just attached
observe(node)
observer being created
Mount: the <div> renders, React calls the ref callback with the real node, the IntersectionObserver is created and observe(node) runs.
<div> node in the tree
watching
observer observing
Observing: the node is live and the observer is watching it. Nothing re-runs — the setup happened once, on attach.
<div> node leaving the tree
disconnect()
observer disconnected
The callback is not re-called with null. The returned cleanup runs instead — that return is the unmount signal now.
Unmount: the <div> leaves the tree, React runs the returned cleanup, observer.disconnect() tears it down.

This feature comes with one TypeScript gotcha worth spotting now. React 19 rejects a ref callback that returns anything other than a cleanup function, undefined, or null. The problem case is the one-line arrow body. An arrow with no braces returns its last expression, so ref={(node) => (mapRef.current = node)} returns the value of the assignment, which is the node, not a cleanup function. The fix is a block body that returns nothing:

<div ref={(node) => (mapRef.current = node)} />

Implicit return. The parenthesized body returns the assignment’s value, the node, which TypeScript reads as a returned non-cleanup. You get a compile error the moment you upgrade to React 19.

Sometimes a component needs its own ref on a node and must forward the caller’s ref to that same node. Suppose <Input> keeps an internal ref on the <input> so it can focus the field after a validation error, and it still forwards the caller’s ref there. That’s two refs on one node, but the ref attribute takes a single value, so ref={a} ref={b} is out. A callback ref solves it: one function, fired with the node, writes into both refs.

const Input = ({ ref, ...props }: ComponentProps<'input'>) => {
const internalRef = useRef<HTMLInputElement>(null);
return (
<input
ref={(node) => {
internalRef.current = node;
if (typeof ref === 'function') ref(node);
else if (ref) ref.current = node;
}}
{...props}
/>
);
};

The component’s internal ref, say to focus the field after a validation error. It must point at the same <input> the caller’s ref does.

const Input = ({ ref, ...props }: ComponentProps<'input'>) => {
const internalRef = useRef<HTMLInputElement>(null);
return (
<input
ref={(node) => {
internalRef.current = node;
if (typeof ref === 'function') ref(node);
else if (ref) ref.current = node;
}}
{...props}
/>
);
};

One callback ref is the single place every ref pointing at this node gets written, fanned out by hand.

const Input = ({ ref, ...props }: ComponentProps<'input'>) => {
const internalRef = useRef<HTMLInputElement>(null);
return (
<input
ref={(node) => {
internalRef.current = node;
if (typeof ref === 'function') ref(node);
else if (ref) ref.current = node;
}}
{...props}
/>
);
};

The component’s own ref: assign the node straight onto internalRef.current.

const Input = ({ ref, ...props }: ComponentProps<'input'>) => {
const internalRef = useRef<HTMLInputElement>(null);
return (
<input
ref={(node) => {
internalRef.current = node;
if (typeof ref === 'function') ref(node);
else if (ref) ref.current = node;
}}
{...props}
/>
);
};

The caller’s ref. It might be a callback (typeof ref === 'function', so call it with the node), an object (ref.current = node), or null (do nothing). That three-way branch is why Ref<T> is a union: each member needs different handling.

1 / 1

That branch is correct, but it’s boilerplate you don’t want to retype on every forwarding component. Teams pull it into a helper, either a mergeRefs function of about five lines or the react-merge-refs package, and the call site collapses to:

<input ref={mergeRefs([internalRef, ref])} {...props} />

A fully correct merge also threads the React 19 cleanup return through each ref, which a hand-rolled five-liner usually skips and the libraries handle. So when a node needs more than one ref, prefer a maintained helper over hand-branching.

Everything so far hands the DOM node to the parent. Occasionally you want to hand it a curated set of methods instead, like ref.current.open() or ref.current.clear(), either because the imperative surface is deliberately small (a <Dialog> exposing only open() and close()) or because exposing the live node would be wrong. useImperativeHandle builds that custom handle:

type FancyInputHandle = {
focus: () => void;
clear: () => void;
};
const FancyInput = (
{ ref, ...props }: ComponentProps<'input'> & { ref?: Ref<FancyInputHandle> },
) => {
const inputRef = useRef<HTMLInputElement>(null);
useImperativeHandle(ref, () => ({
focus: () => inputRef.current?.focus(),
clear: () => {
if (inputRef.current) inputRef.current.value = '';
},
}));
return <input ref={inputRef} {...props} />;
};

The parent’s ref is now Ref<FancyInputHandle>, not Ref<HTMLInputElement>. Calling ref.current?.focus() returns your curated method, never the node.

The dependency-array argument and the custom-hook patterns come later, in the hooks chapter.

This closes the loop the polymorphism lesson left open. Recall the canonical <Button>: it sets Comp = asChild ? Slot : 'button' and spreads {...props} onto Comp. Now {...props} carries ref, because ref is just a prop. The Slot merge you saw in that lesson already concatenates className and composes event handlers, and the same merge forwards the parent’s ref onto its single child. So the ref half comes for free:

const Button = ({ asChild, className, ...props }: ButtonProps) => {
const Comp = asChild ? Slot : 'button';
return <Comp className={cn(buttonVariants(), className)} {...props} />;
};

Now point a ref at it through asChild and watch where it lands:

<Button asChild ref={buttonRef}>
<Link href="/dashboard">Open dashboard</Link>
</Button>

buttonRef lands on the rendered <a>, the element <Link> produces, and you wrote nothing extra to make it happen. The ref flows through {...props} into Slot, and Slot drops it on the child, with no special-casing along the path. That is the payoff of making ref ordinary: it travels through spreads and through Slot’s merge exactly like className. asChild, Slot, cva, and ref-as-prop stop being four separate tricks and read as one contract.

This same surface, ref-as-prop plus Slot forwarding, is what the shadcn primitives in the component-library chapter are built on.

The parent below already creates the ref and wires up a Focus button. Your job is the forward inside <TextField> so the ref reaches the <input>.

Make <TextField> forward its ref to the inner <input> so the parent can focus it. The parent already creates the ref and wires a Focus button — your job is the forward inside TextField: add ref to the destructure and put ref={ref} on the <input>.

Preview
    Reveal the solution

    Pull ref out of the destructure and put it on the <input>.

    const TextField = ({ ref, ...props }: ComponentProps<'input'>) => (
    <input ref={ref} className="rounded-md border px-3 py-2" {...props} />
    );

    These cover the same ground from the React team’s own reference, including edge cases this lesson skipped over.