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.
ref is a regular prop now
Section titled “ref is a regular prop now”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.
forwardRef was the old ceremony
Section titled “forwardRef was the old ceremony”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.
const Input = ({ ref, ...props }: ComponentProps<'input'>) => ( <input ref={ref} {...props} />);Same behavior, wrapper and second parameter gone. ref is a normal prop: destructure it, forward it. No wrapper, no generic pair, no displayName, nothing special left to remember.
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.
Returning a cleanup from a ref callback
Section titled “Returning a cleanup from a ref callback”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> 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.
<div ref={(node) => { mapRef.current = node; }} />Returns nothing. Braces make it a statement body, so the callback returns undefined, which is allowed. The only change is two curly braces.
Merging two refs onto one node
Section titled “Merging two refs onto one node”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.
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.
Exposing methods with useImperativeHandle
Section titled “Exposing methods with useImperativeHandle”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.
Refs through asChild and Slot
Section titled “Refs through asChild and Slot”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.
Practice: forward a ref to the input
Section titled “Practice: forward a ref to the input”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>.
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} />);Going deeper
Section titled “Going deeper”These cover the same ground from the React team’s own reference, including edge cases this lesson skipped over.
The React 19 ref-as-prop model, straight from the docs.
The callback-ref reference, including the cleanup return and its exact behaviour.
The automated migration that rewrites forwardRef to the prop form.
TkDodo on callback-ref cleanup and why useCallback is a perf optimisation, not a correctness fix: the render-model angle this lesson defers.
The merge-refs helper that threads React 19's cleanup return through each ref correctly: the maintained utility to reach for over hand-branching.