Ref as a Prop and Ref Cleanups in React 19: Simplifying DOM Access
For years, passing refs to custom function components has been one of React's most awkward API quirks. While HTML elements accepted ref naturally, passing a ref into a custom child component required wrapping that component in forwardRef. This introduced clunky higher-order function wrappers, disrupted component names in DevTools, and created cumbersome generics in TypeScript.
With the release of React 19, React eliminates this friction entirely. ref is now treated as a first-class prop on function components, forwardRef is officially deprecated (React documentation), and callback refs now support native cleanup functions.
In this tutorial, we explore how passing refs has changed, how component typing is simplified, and how to harness ref cleanup functions to avoid memory leaks.
1. Accessing ref as a Standard Prop
In React 19, function components can accept ref directly as a parameter in their props object, exactly like className, id, or children:
// React 19: Clean and declarative
interface CustomInputProps {
label: string;
placeholder?: string;
ref?: React.Ref<HTMLInputElement>;
}
export function CustomInput({ label, placeholder, ref }: CustomInputProps) {
return (
<div className="input-group">
<label>{label}</label>
<input ref={ref} placeholder={placeholder} />
</div>
);
}
Compare this with the legacy pattern required in React 18:
// Legacy React 18: Requires forwardRef wrapper
import { forwardRef } from 'react';
export const CustomInput = forwardRef<HTMLInputElement, CustomInputProps>(
function CustomInput(props, ref) {
return (
<div className="input-group">
<label>{props.label}</label>
<input ref={ref} placeholder={props.placeholder} />
</div>
);
}
);
Why This Matters
- Cleaner Stack Traces & DevTools: Components maintain their direct function identity without anonymous wrapper layers.
- No Higher-Order Function Overhead: Function components remain standard JavaScript functions that receive a single
propsargument. forwardRefDeprecation: ExistingforwardRefcode continues to work for backward compatibility, but React will deprecate and eventually remove it in future major versions. An official codemod is provided in the React 19 Upgrade Guide to automate this migration across codebases.
2. Ref Callback Cleanup Functions
While useRef handles simple DOM storage, callback refs are the standard mechanism when you need to execute code the moment a DOM node is attached or detached (for example, observing dimensions with ResizeObserver, measuring elements, or binding third-party DOM widgets).
Historically, React called your callback ref with the DOM node upon mounting, and called it again with null upon unmounting:
// Legacy Callback Ref Pattern:
<div
ref={(node) => {
if (node) {
// Element attached
setupObserver(node);
} else {
// Element detached (null check required!)
teardownObserver();
}
}}
/>
The React 19 Model: Native Cleanup Returns
React 19 aligns callback refs with the familiar lifecycle of useEffect. You can now return a cleanup function directly from the ref callback:
// React 19: Return a cleanup function directly
<div
ref={(node) => {
const observer = new ResizeObserver((entries) => {
console.log('Resized:', entries[0].contentRect);
});
observer.observe(node);
// React invokes this cleanup function when the node unmounts
return () => {
observer.disconnect();
};
}}
/>
When the DOM element is removed from the tree, React automatically runs the returned cleanup function. This eliminates defensive if (node) branching and ensures setup and teardown logic remain colocated in a single block.
3. The Implicit Return Gotcha in Arrow Functions
With the introduction of ref cleanups, returning anything other than a cleanup function (or undefined) is invalid.
A common pitfall occurs when developers use concise arrow function syntax with single-statement setters:
// ❌ DANGEROUS / INVALID in React 19:
<div ref={(node) => (myRef.current = node)} />
In JavaScript, assignment expressions evaluate to the assigned value. Here, (myRef.current = node) evaluates to the HTMLDivElement instance!
Because the arrow function implicitly returns this DOM element, React assumes you returned a cleanup function. When unmounting, React attempts to invoke the DOM element as a function, throwing a runtime TypeError: node is not a function.
The Fix
Always ensure your ref callback returns void unless you deliberately intend to return a cleanup function:
// ✅ CORRECT: Block body prevents implicit return
<div
ref={(node) => {
myRef.current = node;
}}
/>
TypeScript catches this in React 19, and the official no-implicit-ref-callback-return codemod helps teams refactor older codebases automatically.
4. Practical Implementation: Animated Tooltip with Measured Bounds
Here is an example demonstrating both ref as a prop and ref cleanup functions in a custom tooltip component:
'use client';
import { useState } from 'react';
interface TooltipBoxProps {
text: string;
ref?: React.Ref<HTMLDivElement>;
}
export function TooltipBox({ text, ref }: TooltipBoxProps) {
const [width, setWidth] = useState<number>(0);
return (
<div
ref={(node) => {
if (!node) return;
// Measure initial width
setWidth(node.getBoundingClientRect().width);
// Track resize events dynamically
const ro = new ResizeObserver(([entry]) => {
setWidth(entry.contentRect.width);
});
ro.observe(node);
// Native React 19 Cleanup
return () => {
ro.disconnect();
};
}}
className="tooltip-container"
>
<div ref={ref} className="tooltip-bubble">
{text} (Measured width: {Math.round(width)}px)
</div>
</div>
);
}
Conclusion
By promoting ref to a standard component prop and introducing symmetric setup/cleanup semantics to callback refs, React 19 removes longstanding boilerplate and cognitive overhead. Component interfaces become more intuitive, TypeScript definitions become concise, and DOM resource management becomes substantially more reliable.