A10 · Part 3 · Advanced
Refs in depth
Forwarding, imperative handles, and integrating libraries that own their own DOM.
08On this page
ref is a prop again
// React 19
function TextField({ ref, ...rest }) {
return <input ref={ref} {...rest} />;
}
// before 19, and in every existing codebase
const TextField = forwardRef(function TextField(props, ref) {
return <input ref={ref} {...props} />;
});
forwardRef existed because ref was intercepted by
React rather than passed through. In 19 function components receive it
normally. You will still read forwardRef constantly, so knowing
both forms matters more than preferring one.
useImperativeHandle: exposing an API instead of a node
function Editor({ ref }) {
const inner = useRef(null);
useImperativeHandle(ref, () => ({
focus: () => inner.current.focus(),
clear: () => { inner.current.value = ""; },
}), []);
return <textarea ref={inner} />;
}
editorRef.current.focus(); // the parent gets two methods, not a DOM node
Handing out the raw node lets a parent do anything to it — including things that fight React. A narrow handle is a contract: these are the imperative operations this component supports.
Use it rarely, and for a reason
A component that needs commanding from outside is usually one that should
have taken a prop. ref.current.open() and
<Modal isOpen /> do the same job; the second is
declarative, testable, and survives being rendered twice. Reach for an
imperative handle when the action genuinely has no state — focus,
scroll, play, reset — where modelling it as state means inventing a
flag you then have to clear.
Callback refs, and the cleanup nobody knew about
<div ref={(node) => {
if (!node) return;
const observer = new ResizeObserver(onResize);
observer.observe(node);
return () => observer.disconnect(); // React 19: cleanup from a ref callback
}} />
Before 19, a callback ref was called with null on unmount and you
cleaned up in that branch. React 19 lets it return a cleanup function
directly, which makes ref callbacks a genuine alternative to an effect for
anything scoped to a node's lifetime.
The callback must be stable or memoised. An inline arrow is a new function each render, so React detaches and reattaches — running your setup and teardown on every render.
Merging refs
function useMergedRefs(...refs) {
return useCallback((node) => {
for (const ref of refs) {
if (typeof ref === "function") ref(node);
else if (ref) ref.current = node;
}
}, refs);
}
<input ref={useMergedRefs(localRef, forwardedRef, register("email").ref)} />
An element takes one ref, and libraries want their own — a
form library, a drag-and-drop hook, and your own measurement all at once. This
ten-line helper is why react-merge-refs exists.
Integrating a library that owns its DOM
function Map({ center, markers }) {
const container = useRef(null);
const map = useRef(null);
useEffect(() => {
map.current = new MapLib(container.current); // create once
return () => map.current.destroy();
}, []);
useEffect(() => { map.current?.setCenter(center); }, [center]); // sync props in
useEffect(() => { map.current?.setMarkers(markers); }, [markers]);
return <div ref={container} />; // React never touches inside this
}
The pattern is always the same: one effect to create and destroy, one per prop to synchronise, and a container React renders empty and then leaves alone. React must not manage children of a node another library controls — they will fight, and the reconciler will lose in confusing ways.
Focus management, which is mostly refs
useEffect(() => {
const previous = document.activeElement; // remember where focus was
dialogRef.current?.focus();
return () => previous?.focus(); // put it back on close
}, []);
Returning focus to the element that opened a dialog is the difference between a keyboard user continuing where they were and being dumped at the top of the document. It is three lines and it is skipped constantly.
Fragment refs: one ref for a group of nodes
Before React 19.3, a ref could only point at one element, so observing or
listening to a group of siblings meant adding a wrapper <div>
— which changes layout, breaks CSS grid and flex, and adds a node the
accessibility tree has to explain. Since 19.3 a <Fragment>
takes a ref, and you get a FragmentInstance.
import { Fragment, useEffect, useRef } from "react";
function SeenTracker({ children, onSeen }) {
const ref = useRef(null);
useEffect(() => {
const observer = new IntersectionObserver((entries) => {
if (entries.some((e) => e.isIntersecting)) onSeen();
});
ref.current.observeUsing(observer); // every first-level DOM child
return () => ref.current?.unobserveUsing(observer);
}, [onSeen]);
return <Fragment ref={ref}>{children}</Fragment>; // no wrapper div
}
| Method | What it touches |
|---|---|
addEventListener, removeEventListener, dispatchEvent | First-level DOM children |
observeUsing, unobserveUsing | First-level DOM children, with an IntersectionObserver or ResizeObserver |
getClientRects | Rects of the first-level DOM children |
focus, focusLast, blur | The first or last focusable node, searched deeply |
scrollIntoView, compareDocumentPosition, getRootNode | The fragment's position in the document |
- The shorthand cannot take it.
<>...</>accepts neitherkeynorref; write<Fragment ref={ref}>. - First-level means first-level. Listeners and observers attach to the fragment's direct DOM children, not to elements nested inside them. Focus is the exception and searches all the way down.
- Hidden Activity trees are skipped. Listeners are added when an
<Activity>becomes visible, not before. focus()is the practical win. A modal or a list can focus its first focusable child without knowing what it is — which is the focus-management problem above, solved without a wrapper.
The rules that keep refs safe
- Never read or write
ref.currentduring render. Rendering must be pure and may run twice. - Do not fight React for a node it owns. Setting
textContenton something React renders will be overwritten, or worse, will not be. - A ref is not state. If the UI must change when it changes, it is state.
- Prefer declarative. An imperative handle is an escape hatch, and every one you add is a way for a parent to break your component's assumptions.
Interview answer, one sentence
"Refs are the escape hatch to the DOM and to values that persist without
rendering. useImperativeHandle narrows what a parent can do to
a small API instead of a raw node, and the standard pattern for a
third-party widget is one effect to create and destroy plus one per prop to
synchronise — with React never rendering inside the container."
Opens in the editor: write it, run it, and check it against real tests.
All 54 for this level →Finished reading?
0 of 57 chapters read in this series