React Fragment Refs in 19.3: How FragmentInstance Works
In React 19.3 you can put a ref on a <Fragment>. The ref doesn’t hold a DOM node. It holds a FragmentInstance, an object that lets you add event listeners, move focus, attach observers, measure and scroll across the Fragment’s DOM children as a group, without adding a wrapper element to the page.
Fragment refs became stable in React 19.3, released on September 9, 2026, alongside View Transitions. The same release added an API for components that should only render in the browser, which we covered in our breakdown of how React 19.3’s browser() API handles browser-only rendering. Here we stick to Fragment refs: what each method does, which children it reaches, and where it behaves differently from the DOM method it’s named after.
The problem Fragment refs solve
Before 19.3, if you wanted to observe or listen to whatever a component rendered, you needed a DOM element to hold the ref. There were two ways to get one, and React’s release post names the downside of each.
Wrapping the children in an extra <div> is the quick fix, but that div can interfere with your styling or layout. A flex or grid parent suddenly has one child instead of five, or a :first-child selector starts matching the wrapper.
The other way is to make the child component expose a ref prop. That means editing the component, which you can’t do when it comes from a library you don’t control.
Fragment refs replace both with a limited set of commonly used DOM methods that work with any component, whatever it renders. Take “limited” literally. A FragmentInstance is not an element, so you can’t style it, give it a class or use it as a layout box. If you need a real box, you still need a real element.
Attaching a ref to a Fragment
The <>...</> shorthand can’t take a ref, so import Fragment from react and write it out in full:
import { Fragment, useRef } from 'react';
function ProductGrid({ products }) {
const fragmentRef = useRef(null);
return (
<Fragment ref={fragmentRef}>
{products.map((product) => (
<ProductCard key={product.id} product={product} />
))}
</Fragment>
);
}
The ref prop takes either a useRef object or a ref callback, the same as on a regular element. What you get back is a FragmentInstance, not a DOM node.
Nothing is added to the DOM. React targets the Fragment’s first level of host (DOM) children and passes element-like calls through to them. Most methods act on each of those children. The main exceptions are the focus methods, which search deeper, and dispatchEvent, which fires on the Fragment itself (both covered below). React’s release post groups the methods like this:
- Events:
addEventListener,removeEventListeneranddispatchEventmanage events for first-level children. - Focus:
focus,focusLastandblurmove focus across nested children, depth-first. - Observers:
observeUsingandunobserveUsingconnect anIntersectionObserveror aResizeObserver. - Measuring and scrolling:
getClientRects,getRootNode,compareDocumentPositionandscrollIntoViewwork on first-level children.
The depth rule: first-level children vs depth-first search
Methods that target children, such as addEventListener, observeUsing and getClientRects, only reach the Fragment’s first-level host children. They don’t directly target elements nested inside another DOM element.
A first-level host child is the first real DOM element under the Fragment, even when a nested component renders it:
function Card({ title }) {
return (
<div className="card">
<span>{title}</span>
</div>
);
}
<Fragment ref={ref}>
<Card title="Starter" />
<Card title="Team" />
</Fragment>
Here the two div.card elements are the first-level host children, and the span elements inside them are not targeted. Card isn’t a DOM node, so React looks straight through it to the div.
You still get events from the spans. A listener on each div.card receives events that bubble up from inside it, which is ordinary DOM behavior. What you can’t do through the Fragment is observe or measure a span on its own.
The focus methods work differently. focus() searches all nested children depth-first until it finds a focusable element, where element.focus() on a DOM node only tries that node. An input three levels down inside a child component is reachable.
Event listeners on a group of children
addEventListener adds the listener to every first-level DOM child of the Fragment. When children are added or removed later, the FragmentInstance adds or removes the listener to match, so you don’t re-subscribe when a list grows.
One use is spotting unsaved edits across field components you didn’t write. The input event bubbles, so one listener on the Fragment catches typing in any field inside it:
import { Fragment, useEffect, useRef } from 'react';
function DirtyTracker({ onDirty, children }) {
const fragmentRef = useRef(null);
useEffect(() => {
const fragment = fragmentRef.current;
const controller = new AbortController();
fragment.addEventListener('input', () => onDirty(true), {
signal: controller.signal,
});
return () => controller.abort();
}, [onDirty]);
return <Fragment ref={fragmentRef}>{children}</Fragment>;
}
Listeners registered with options.signal are removed when the signal is aborted, so this cleanup works. Calling removeEventListener in the cleanup function works too.
React doesn’t apply these listeners to hidden <Activity> trees. It applies them automatically when the boundary switches to visible.
dispatchEvent dispatches an event on the Fragment itself: the listeners you added are called, and the event can bubble on to the Fragment’s DOM parent. It returns false if a listener called preventDefault() and true otherwise.
const allowed = fragmentRef.current.dispatchEvent(
new CustomEvent('panel-close', { bubbles: true, cancelable: true })
);
if (allowed) {
closePanel();
}
Focus management without forwarded refs
focus() focuses the first focusable element, searching depth-first. focusLast() searches nested children depth-first, then iterates in reverse to find the last focusable node. blur() removes focus if the active element is inside the Fragment and does nothing if it isn’t. In a Fragment that contains only text, all three do nothing.
The usual case is focusing the first field of a form component that doesn’t forward a ref:
import { Fragment, useCallback } from 'react';
import { VendorAddressForm } from 'some-ui-library';
function CheckoutStep() {
const focusFirstField = useCallback((fragment) => {
if (fragment) {
fragment.focus();
}
}, []);
return (
<Fragment ref={focusFirstField}>
<VendorAddressForm />
</Fragment>
);
}
Keep the useCallback. Without it the ref callback is a new function on every render, and focus() runs again each time CheckoutStep re-renders. That pulls the cursor back to the first field while the user is typing somewhere else.
In a custom dialog, focus() and focusLast() give you both ends of a focus wrap, so you don’t have to query for focusable selectors yourself.
IntersectionObserver and ResizeObserver with observeUsing
observeUsing starts observing all first-level DOM children of the Fragment with the observer you pass in. A visibility wrapper takes a few lines:
import { Fragment, useEffect, useRef } from 'react';
function InView({ onEnter, children }) {
const fragmentRef = useRef(null);
useEffect(() => {
const fragment = fragmentRef.current;
const observer = new IntersectionObserver((entries) => {
if (entries.some((entry) => entry.isIntersecting)) {
onEnter();
}
});
fragment.observeUsing(observer);
return () => {
fragment.unobserveUsing(observer);
observer.disconnect();
};
}, [onEnter]);
return <Fragment ref={fragmentRef}>{children}</Fragment>;
}
onEnter runs on every delivery where at least one child is intersecting, so it fires again each time the group scrolls back into view. For a once-per-mount impression, set a flag in a ref the first time and check it before calling onEnter.
Each child component contributes its outer element, not the elements inside it. observeUsing doesn’t work on text nodes, and React logs a development warning if the Fragment contains only text.
Children that mount after you call observeUsing are added to the observers already attached. When a child is removed, React keeps it in an IntersectionObserver until the next delivery, so an observer like the one in InView still receives the exit entry.
If you track many groups, you don’t need one observer per Fragment. Each first-level DOM child of a Fragment with a ref has a reactFragments property, a Set of the FragmentInstance objects that contain it. One shared observer can route each entry back to its owner:
const handlers = new Map(); // FragmentInstance -> callback
const sharedObserver = new IntersectionObserver((entries) => {
for (const entry of entries) {
for (const fragment of entry.target.reactFragments ?? []) {
handlers.get(fragment)?.(entry);
}
}
});
// In each component's Effect:
// handlers.set(fragment, onEntry);
// fragment.observeUsing(sharedObserver);
// cleanup: fragment.unobserveUsing(sharedObserver); handlers.delete(fragment);
You lose one thing with this pattern. When a child is removed, React defers only the IntersectionObserver unobserve. It takes the Fragment out of that child’s reactFragments set straight away, so the late exit entry arrives with nothing to route to and the loop above skips it. If you need to know when a removed child left, track that in the component’s own state, not through the shared observer.
Measuring, ordering and scrolling
getClientRects() returns a flat array of DOMRect objects for the bounding rectangles of all first-level DOM children. Text children are measured with the Range API. For the box around a whole group, combine the rects:
useLayoutEffect(() => {
const rects = fragmentRef.current.getClientRects();
if (rects.length === 0) return; // empty Fragment: nothing to measure
const top = Math.min(...rects.map((r) => r.top));
const bottom = Math.max(...rects.map((r) => r.bottom));
setGroupHeight(bottom - top);
}, []);
Don’t drop the early return. With no children, Math.min() and Math.max() called with no arguments return Infinity and -Infinity, and the height comes out as -Infinity.
The code uses useLayoutEffect because what runs inside it, and any state updates it schedules, are processed before the browser repaints. It also holds up the repaint, so use it only when you need the numbers before the user sees the frame. Otherwise useEffect is enough.
getRootNode() returns a Document, a ShadowRoot, or the FragmentInstance itself when there is no parent DOM node. Reach for it when your component might render inside a shadow root.
compareDocumentPosition(node) returns the usual position bitmask. Empty Fragments, and Fragments whose children render through a portal, include Node.DOCUMENT_POSITION_IMPLEMENTATION_SPECIFIC in the result, so check that bit before you trust the ordering.
scrollIntoView is where the API departs most from the DOM version. It accepts only the boolean alignToTop. With true or no argument, it aligns the first child with the top of the scrollable ancestor. With false, it aligns the last child with the bottom. It doesn’t accept an options object, and passing one throws an error. If the Fragment has no children, it scrolls the nearest sibling or parent into view instead.
sectionRef.current.scrollIntoView(); // first child to the top
sectionRef.current.scrollIntoView(false); // last child to the bottom
// Throws: options objects are not supported
// sectionRef.current.scrollIntoView({ behavior: 'smooth' });
Where to call FragmentInstance methods
Call them from Effects, ref callbacks or event handlers, which only run in the browser. Effects never run during server rendering. Don’t call them while rendering. A Fragment ref is like any other ref: you read it after React has updated the DOM, and your render output shouldn’t depend on it.
Upgrading and TypeScript
You need react and react-dom 19.3 or later. As of October 22, 2026, the latest on npm is 19.3.1, released October 14, 2026. Its only change is to the Server Components transport protocol, nothing related to Fragment refs.
For TypeScript, @types/react 19.3.0 types the Fragment ref prop as Ref<FragmentInstance>, and @types/react-dom 19.3.0 declares DOM methods on FragmentInstance, such as scrollIntoView(alignToTop?: boolean) and observeUsing. Upgrade both type packages, since the methods come from the react-dom one.
Not every documented method is declared yet. As of October 22, 2026, compareDocumentPosition is missing from that interface, so calling it in TypeScript needs a cast until the types catch up. Check the installed index.d.ts for the methods you call.
If you maintain a component library, a component that renders a Fragment can now pass a consumer’s ref through to it. The consumer gets a FragmentInstance and you don’t add a wrapper element.
Fragment refs are a small API, and most of the payoff is code you get to delete: wrapper divs that only existed to hold a ref, and components edited only to forward one. If you are planning a React upgrade or a new build on the full JavaScript stack, our MERN development team can help you work out where these APIs fit.
Frequently asked questions
What is a Fragment ref in React?
It’s a ref passed to <Fragment>. Instead of a DOM node, it gives you a FragmentInstance that operates on the Fragment’s DOM children as a group without changing the DOM structure.
Can I put a ref on the <>…</> shorthand?
No. To pass a ref you have to import Fragment from react and use <Fragment ref={...}>.
Do Fragment ref event listeners reach nested elements?
They are attached only to first-level host children. Events that bubble up from deeper elements still reach those listeners, but you can’t observe or measure a nested element directly through the Fragment.
Does FragmentInstance.scrollIntoView support smooth scrolling?
Not through the call. It takes only the alignToTop boolean, and passing an options object such as { behavior: 'smooth' } throws an error.
Which React version has stable Fragment refs?
React 19.3, released September 9, 2026. As of October 22, 2026, version 19.3.1 is the npm latest.
How long have Fragment refs been in development?
The first implementation PR was merged into the React repository in March 2025. In April 2025 the React team said the API was still being researched, and it was presented as a Canary feature at React Conf 2025 before becoming stable in 19.3.
Is a FragmentInstance a DOM element?
No. It exposes a limited set of commonly used DOM methods. You can’t style it or use it as a layout box, so if you need one, render a real element.
- Founded
- 2014
- Years in business
- 12
- People
- 66
- Headquarters
- Lahore, Pakistan
- Projects delivered
- 250+
- AI systems in production
- 10

