Skip to content
Chapter 22Lesson 4

Refs as a regular prop

How React 19 lets a parent reach the DOM node inside your component by treating ref as an ordinary prop, retiring the old forwardRef ceremony.

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

This lesson answers one narrow question: how does a ref cross the component boundary? You already hand onClick, className, and ...rest down to the inner element. In React 19, ref is now just one more of those: an ordinary prop you destructure and pass along. Before React 19 this took a dedicated API called forwardRef and a few lines of ceremony per component, and React 19 removed that ceremony. By the end you’ll write a focusable <Input>, send a ref through asChild so it lands on a rendered <a>, and wire a callback ref to an observer. You’ll also learn to recognise forwardRef when you read it in older code, 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} />
);

Look closely at that destructure, because the key detail is in the type. You did not add a ref field to a props type. ComponentProps<'input'>, the element-props alias you’ve used since the typed-props contract lesson, already includes ref, already typed against HTMLInputElement. The ref comes in for free with the same alias that gives you value, onChange, and every other native attribute. Pull ref out of the destructure, drop it on the <input>, and the forward is done.

This is the arrow-bound-to-const form the rest of the course uses, and it’s the shape you should ship. A plain function Input({ ref, ...props }: ComponentProps<'input'>) { ... } declaration is exactly equivalent, since ref is a real parameter either way, but stay with the arrow form so your components all read the same.

The parent side is small, and it’s the only place useRef shows up in this lesson:

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

useRef(null) produces the ref, and passing it as ref={inputRef} is the whole job here. React assigns the rendered <input> to inputRef.current once it mounts, so the parent can later call inputRef.current?.focus(). How .current works in full, meaning how a ref stores a value, when it’s safe to read, and what else it’s good for, gets its full treatment in the lesson on useRef later in the next chapter. For now, treat useRef(null) as nothing more than the thing that produces the ref you pass down.

If ref is just a prop now, what did this look like before? Every codebase written before 2024, every shadcn component file before mid-2025, and every pre-React-19 tutorial wrapped its components in forwardRef. You’ll read it sooner or later, so it’s worth fixing 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 so the component showed up named in DevTools. That’s six lines of boilerplate on every component that wanted a ref.

forwardRef still runs in React 19, but it logs a deprecation warning and is slated for removal in a future major version, 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 forwardRef call to the prop form for you, and ESLint flags the redundant wrappers so they don’t creep back in. Because a script converts the old ref code for you, a forwardRef-heavy repo is a quick migration rather than a rewrite.

Why did React bother removing it? ref used to be reserved by the runtime, the same way key is. React intercepted it before it ever reached your component, so a component simply could not receive ref through props, and forwardRef existed only to pass the reserved prop back in. Making ref ordinary removes that special case. The contract shrinks, and the React Compiler, already enabled in this course’s stack, can reason about ref exactly like any other prop instead of treating it as a runtime quirk. You’ll see the same move again with the 'use client' and 'use server' directives in the App Router chapter: React 19 keeps trading special-cased behaviour for plain, inspectable contracts.

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 at all, because ComponentProps<'input'>['ref'] resolves it for you. You reach for the explicit type in exactly one situation: when your props type is not an element-props alias, meaning a component whose props you wrote by hand that should nonetheless 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 actually type. When you hand-write a component’s props and still want it to accept a ref, you add ref?: Ref<HTMLDivElement> yourself. Notice it’s a union, not a single object type. That union of object, function, or null is worth keeping in mind: it pays off when you merge two refs onto one node later in this lesson.

RefObject<T> is what a useRef call hands back, and its .current holds the value. There’s one historical wrinkle worth clearing up so older code doesn’t trip you. Before React 19, RefObject<T> meant “.current is readonly” and a separate MutableRefObject<T> meant “writable .current”, two types for the same idea. React 19 collapsed them: RefObject<T> now has a writable .current, and MutableRefObject is deprecated. You only ever write RefObject. The only reason to know MutableRefObject exists is to recognise it in pre-2025 code and not copy it forward. (A refobject-defaults codemod handles the related useRef(null) argument change automatically, so there’s nothing to do by hand.)

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

The decision rule is the whole takeaway here: with an element-props alias the ref type comes for free, and with hand-written props you annotate ref?: Ref<T>.

To check that, here’s a component with hand-written props, not a ComponentProps<'tag'> alias, that 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, meaning an object ref or a callback ref, so that’s the type a hand-written prop must declare to match it. RefObject<HTMLDivElement> alone 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()} />

Why would you want that? Some work needs the DOM node at the instant it attaches, not whenever you happen to read .current later. An object ref gives you the node after the fact, while a callback ref gives it to you on attach. That timing is what you need to wire up an observer, measure the element’s size, or attach a non-React event listener the moment it exists. The canonical example, which this course comes back to for lazy-loading images, is hooking an IntersectionObserver to a node so you get 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);
}} />

The IntersectionObserver is a browser API that fires a callback when an element enters or leaves the viewport, and you’ll meet it properly later when the course covers lazy-loading. Ignore its details for now. The point is that it needs the real <div> node to start watching, and the callback ref is what hands that node over at the right moment.

One note on stability, kept brief because the full story belongs to the render model in the next chapter. An inline callback ref like the one above is a brand-new function on every render. Without the cleanup return you’re about to learn, React responds to a new function by running the old one with null and then the new one with the node, on every render. If the setup is expensive, wrapping the callback in useCallback gives React a stable function so it stops re-running. Take that as a fact for now; why a new function triggers a re-run is a render-model question the next chapter answers.

Wiring up an observer raises an obvious question: who tears it down? An observer you create but never disconnect keeps a live reference to a node that’s left the page, which is a memory leak. React 19 added the 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 the life of one DOM node. Setup runs when the node attaches, the returned function runs when it detaches, and you never reach for the useEffect and useRef pairing people used to write for this. Setup and teardown live side by side in the same callback.

One behavioural detail is worth getting right, because it changes how the old mental model maps onto the new API. When you return a cleanup, React no longer calls the callback again with null on unmount: the returned function is the unmount signal now. The pre-React-19 pattern was a single callback invoked twice, once with the node and once with null, and you branched on which one you got. The React 19 pattern is setup then cleanup, two separate functions, with no null call. Here’s the lifecycle:

<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 that tends to surface right after you upgrade, so it’s worth spotting now. TypeScript in 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 implicitly returns its last expression, so ref={(node) => (mapRef.current = node)} returns the value of the assignment, which is the node. The node is not a cleanup function, so this is now a type error. The fix is a block body that returns nothing:

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

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

This is the genuinely tricky case, and it’s where the Ref<T> union from earlier earns its keep. Suppose your <Input> needs to keep its own internal ref on the <input>, so it can focus itself when some event fires, and it still has to forward the caller’s ref to that same <input>. That’s two refs on one node. The ref attribute takes exactly one value, so you can’t write ref={a} ref={b}. 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 owns an internal ref, say to focus the field after a validation error. It needs to point at the same <input> the caller’s ref points at.

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, fired with the node, is the single place where every ref that needs this node gets written. You can’t pass two refs to one ref attribute, so you fan one 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}
/>
);
};

Wire 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}
/>
);
};

Now 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 exactly why Ref<T> is a union and not just an object: each member needs different handling.

1 / 1

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

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

Recognise the pattern, then reach for the utility. One caveat worth knowing: a fully correct merge in React 19 also threads the cleanup return through each ref, and a hand-rolled five-liner usually skips that, while the libraries handle it. The react-best-merge-refs package goes out of its way to get the React 19 cleanup path right. When a node needs more than one ref, prefer a maintained helper over hand-branching it every time.

useImperativeHandle: the rare escape valve

Section titled “useImperativeHandle: the rare escape valve”

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(), ref.current.scrollToBottom(), or ref.current.clear(), rather than the raw node. You’d do this either because the imperative surface is deliberately small (a <Dialog> that exposes open() and close() and nothing else) or because handing out the live node wholesale 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} />;
};

Notice the type change: the parent’s ref is Ref<FancyInputHandle> now, not Ref<HTMLInputElement>. The parent calls ref.current?.focus() and gets your curated method, never the node.

This lesson shows you the shape and the rule, nothing more. The API in depth, meaning the dependency-array third argument and the patterns for a real custom hook, comes later in the hooks chapter, where a custom hook earns it.

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

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 that happen. The ref-as-prop flows through {...props} into Slot, and Slot drops it on the child, with no special-casing anywhere along the path. That’s the whole payoff of making ref ordinary: it travels through {...props} spreads and through Slot’s merge exactly like className does. asChild, Slot, cva, and ref-as-prop stop being four separate tricks and read as one coherent contract.

Ref-as-prop plus Slot forwarding is the exact surface every shadcn primitive in the component-library chapter is built on, which is why a ref works through their asChild prop without any of them doing anything special. You now know how to write the thing those components are made of.

One skill carries this whole lesson: ref-as-prop on a leaf component, forwarded to a DOM node. Put it into practice by writing the forward yourself. The parent below already creates the ref and focuses on demand, so your only job is the four-character change inside <TextField> that lets the ref reach 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>, the same forward as the canonical <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.