Home / Articles

Fragment Refs in React 19.3: DOM Behavior for Sibling Groups, No Wrapper

Learn how a ref on Fragment gives you a FragmentInstance for listeners, focus, observers and measurement across sibling elements, and where its limits are.

Publisher: REACTAPP.TOP12 min read2858 words

This article is published in English.

Sources: React 19.3 Fragment Refs: Listen, Focus, Observe and Measure a Group of Elements Without a Wrapper div

Most React codebases contain a <div> that exists only so something could hold a ref. Perhaps the component you needed to measure rendered several siblings, or it came from a library that never forwarded its ref. The extra element then breaks a grid or flex layout, disturbs :nth-child styling or, around <tr> elements, produces invalid HTML. React 19.3 marks Fragment Refs as stable: when you attach a ref to <Fragment>, you receive a FragmentInstance that applies common DOM operations such as listening, focusing, observing, measuring and scrolling across all the fragment's children at once, and adds nothing to the rendered tree. This article covers the API, practical patterns for each capability, which children each method targets, and the pitfalls to know before shipping.

Because the feature is new at the time of writing, check the current <Fragment> reference for any changes to method names or behavior.

The FragmentInstance API

A typical setup imports Fragment explicitly, since the ref only exists after mount:

import { Fragment, useRef, useEffect } from 'react';

The ref starts as null and becomes a FragmentInstance once the fragment mounts. The effect below guards against null, attaches a listener and removes it on cleanup:

function Group({ children }) {
  const ref = useRef(null);            // becomes a FragmentInstance after mount
  useEffect(() => {
    const instance = ref.current;
    if (instance === null) return;
    instance.addEventListener('click', onClick);
    return () => instance.removeEventListener('click', onClick);
  }, []);
  return <Fragment ref={ref}>{children}</Fragment>;
}

The explicit <Fragment> element is required. The <>…</> shorthand accepts neither a ref nor a key. Here is what the instance provides:

  • addEventListener(type, fn, opts?) and removeEventListener: operate on the first-level DOM children, and registered listeners are carried over to children that mount or unmount afterwards.
  • dispatchEvent(event): dispatches on the fragment itself; with bubbles: true the event propagates to the parent DOM node.
  • focus(opts?) and focusLast(opts?): search all nested descendants depth-first for the first or last focusable element, and accept { preventScroll }.
  • blur(): blurs the active element if it lives inside the fragment, and does nothing otherwise.
  • observeUsing(observer) and unobserveUsing(observer): register first-level DOM children with an IntersectionObserver or ResizeObserver; text nodes are not included.
  • getClientRects(): returns a flat DOMRect[] for the first-level DOM children.
  • scrollIntoView(alignToTop?): scrolls the first child into view for true or the last child for false; only a boolean is accepted, and an options object throws.
  • getRootNode() and compareDocumentPosition(node): describe the fragment's position with the same contracts as the Node methods.

One listener for a group of children

A common scenario is a toolbar rendered by a component you do not control, where you want a single click listener across all its buttons, for instance to feed analytics. The demo imports:

import { Fragment, useRef, useEffect, useState } from 'react';

Toolbar stands in for a third-party component: it renders bare sibling buttons with no wrapper and no ref, plus an optional fourth button. Tracked wraps its children in a ref'd Fragment and attaches the onAny handler in an effect, re-attaching when the handler changes. App logs the text of each clicked button and offers a checkbox that adds the extra one:

// Imagine this comes from a library: no ref, no wrapper, just siblings.
function Toolbar({ extra }) {
  return (
    <>
      <button>Bold</button>
      <button>Italic</button>
      <button>Link</button>
      {extra && <button>Emoji</button>}
    </>
  );
}function Tracked({ children, onAny }) {
  const ref = useRef(null);
  useEffect(() => {
    const instance = ref.current;
    if (instance === null) return;
    instance.addEventListener('click', onAny);
    return () => instance.removeEventListener('click', onAny);
  }, [onAny]);
  return <Fragment ref={ref}>{children}</Fragment>;
}export default function App() {
  const [log, setLog] = useState([]);
  const [extra, setExtra] = useState(false);
  return (
    <>
      <div className="bar">
        <Tracked onAny={e => setLog(l => [e.target.textContent, ...l].slice(0, 6))}>
          <Toolbar extra={extra} />
        </Tracked>
      </div>
      <label><input type="checkbox" checked={extra} onChange={e => setExtra(e.target.checked)} /> add a fourth button</label>
      <p className="log">clicked: {log.join(' · ') || '—'}</p>
    </>
  );
}

The styles only lay out the toolbar and the log line:

.bar { display: flex; gap: 8px; margin-bottom: 10px; }
.log { margin: 10px 0 0; font-family: ui-monospace, monospace; opacity: .8; }

Enable the fourth button and click it, and it is logged like the others. The fragment instance tracks children as React mounts and unmounts them and updates its listeners accordingly, which is exactly the bookkeeping you would otherwise have to do yourself. Note that onAny is an inline arrow in the demo, so the effect re-subscribes on every render of App; in real code, stabilise it with useCallback if that churn matters.

Moving focus through nested content

Only focus() and focusLast() look beyond the first level. They traverse every descendant in depth-first order until they find a focusable element, so an input tucked inside a label inside a fieldset is still reachable. That makes "focus the first field in this section" trivial even for markup you do not own.

The demo imports only Fragment and useRef:

import { Fragment, useRef } from 'react';

Section renders a title and three buttons, wired to focus(), focusLast() and blur() on the fragment instance, then renders its children inside the ref'd Fragment. App passes two fieldsets containing labelled inputs:

function Section({ title, children, controls }) {
  const ref = useRef(null);
  return (
    <>
      <div className="row">
        <strong>{title}</strong>
        <button onClick={() => ref.current.focus()}>First field</button>
        <button onClick={() => ref.current.focusLast()}>Last field</button>
        <button onClick={() => ref.current.blur()}>Blur</button>
      </div>
      <Fragment ref={ref}>{children}</Fragment>
    </>
  );
}export default function App() {
  return (
    <Section title="Shipping">
      <fieldset>
        <legend>Address</legend>
        <label>Street <input name="street" /></label>{' '}
        <label>City <input name="city" /></label>
      </fieldset>
      <fieldset>
        <legend>Contact</legend>
        <label>Phone <input name="phone" /></label>
      </fieldset>
    </Section>
  );
}

A focus ring makes the result visible:

.row { display: flex; gap: 8px; align-items: center; margin-bottom: 10px; }
fieldset { border-radius: 8px; margin: 0 0 8px; }
input:focus { outline: 3px solid light-dark(#2563eb, #60a5fa); }

"First field" lands on Street, although it sits inside a <label> within a <fieldset>, and "Last field" lands on Phone. This depth-first search is different from events and observers, which stop at the first level; the section on targeting below explains why.

Watching visibility without breaking layout

observeUsing() accepts an observer you create yourself, so you keep control over root, threshold and rootMargin, and the instance registers every first-level child with it. The next example flags the latest few chat messages as seen as soon as one of them becomes visible inside the scrolling conversation. A wrapper element around those messages would have disrupted the pane's flex layout, where each bubble aligns itself independently.

The imports include useLayoutEffect so the observer is registered before paint:

import { Fragment, useRef, useLayoutEffect, useState } from 'react';

ReadReceipt builds an IntersectionObserver rooted at the scrolling pane with a 0.6 threshold, calls onRead when any entry intersects, and cleans up with unobserveUsing. App renders fourteen messages, wrapping the last three in ReadReceipt:

function ReadReceipt({ root, onRead, children }) {
  const ref = useRef(null);
  useLayoutEffect(() => {
    const instance = ref.current;
    const io = new IntersectionObserver(
      entries => { if (entries.some(e => e.isIntersecting)) onRead(); },
      { root: root.current, threshold: 0.6 }
    );
    instance.observeUsing(io);
    return () => instance.unobserveUsing(io);
  }, [root, onRead]);
  return <Fragment ref={ref}>{children}</Fragment>;
}const MESSAGES = Array.from({ length: 14 }, (_, i) => `Message ${i + 1}`);export default function App() {
  const pane = useRef(null);
  const [read, setRead] = useState(false);
  return (
    <>
      <p className="status">{read ? '✅ Priya has read your last messages' : 'scroll the pane to the bottom…'}</p>
      <div className="pane" ref={pane}>
        {MESSAGES.slice(0, 11).map(m => <div key={m} className="bubble">{m}</div>)}
        <ReadReceipt root={pane} onRead={() => setRead(true)}>
          {MESSAGES.slice(11).map(m => <div key={m} className="bubble mine">{m}</div>)}
        </ReadReceipt>
      </div>
    </>
  );
}

The CSS turns the pane into a scrollable flex column and aligns the user's own bubbles to the right:

.status { margin: 0 0 8px; font-weight: 600; }
.pane { height: 190px; overflow: auto; display: flex; flex-direction: column; gap: 6px; padding: 8px; border-radius: 10px; background: light-dark(#f1f5f9, #262a33); }
.bubble { align-self: flex-start; padding: 6px 10px; border-radius: 12px; background: light-dark(#fff, #363b47); }
.mine { align-self: flex-end; background: light-dark(#dbeafe, #1e3a5f); }

ResizeObserver works identically. If you manage many groups that share the same observer options, you do not need an observer per group: every first-level child carries a reactFragments property holding a Set of the fragment instances it belongs to, which lets a single observer look up the owning group for each entry and call the right handler.

Measuring a group of siblings

getClientRects() returns one rectangle per first-level child. Combining them gives you the bounding box of the whole group, which you can use to draw a selection highlight or a bracket around several invalid fields without touching the layout the children live in.

The demo imports:

import { Fragment, useRef, useState, useLayoutEffect } from 'react';

Outline reads the rects in a layout effect when it becomes active, computes the minimum top and left and maximum bottom and right, adds scroll offsets and a little padding, and stores the result. It then renders an absolutely positioned outline next to the fragment. App places two groups of three inputs in the same grid and lets you choose which to outline:

function Outline({ active, children }) {
  const ref = useRef(null);
  const [box, setBox] = useState(null);
  useLayoutEffect(() => {
    if (!active) return setBox(null);
    const rects = ref.current.getClientRects();
    const top = Math.min(...rects.map(r => r.top)), left = Math.min(...rects.map(r => r.left));
    const bottom = Math.max(...rects.map(r => r.bottom)), right = Math.max(...rects.map(r => r.right));
    setBox({ top: top + scrollY - 6, left: left + scrollX - 6, width: right - left + 12, height: bottom - top + 12 });
  }, [active]);
  return (
    <>
      <Fragment ref={ref}>{children}</Fragment>
      {box && <div className="outline" style={box} />}
    </>
  );
}export default function App() {
  const [which, setWhich] = useState('none');
  return (
    <>
      <div className="row">
        {['none', 'address', 'card'].map(w => (
          <label key={w}><input type="radio" checked={which === w} onChange={() => setWhich(w)} /> {w}</label>
        ))}
      </div>
      <div className="grid">
        <Outline active={which === 'address'}>
          <input placeholder="Street" /><input placeholder="City" /><input placeholder="PIN" />
        </Outline>
        <Outline active={which === 'card'}>
          <input placeholder="Card number" /><input placeholder="MM/YY" /><input placeholder="CVV" />
        </Outline>
      </div>
    </>
  );
}

The grid has three columns, and the outline is absolutely positioned with pointer-events: none so it never intercepts clicks:

.row { display: flex; gap: 14px; margin-bottom: 10px; }
.grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; }
.grid input { min-width: 0; }
.outline { position: absolute; pointer-events: none; border: 2px dashed light-dark(#dc2626, #f87171); border-radius: 8px; }

Every input here is an immediate child of the same CSS grid. Wrapping each group in a <div> would squeeze its three inputs into one cell; with a fragment, the grid is unaffected and each group can still be measured. One limitation of the demo is that the box is computed only when active changes, so a window resize or a scroll container that moves would leave it stale. Pairing it with a ResizeObserver through observeUsing fixes that.

Which children each method targets

Most methods operate on what React calls first-level host children, meaning the DOM elements you reach by descending through React components until you meet an actual element. Elements nested inside those are not targeted directly.

<Fragment ref={ref}>
  <div id="A" />
  <Wrapper>              {/* a component, not a DOM node: looked through */}
    <div id="B">
      <div id="C" />     {/* inside a DOM node: NOT targeted */}
    </div>
  </Wrapper>
  <div id="D" />
</Fragment>
// addEventListener / observeUsing / getClientRects → A, B, D
// focus / focusLast → search A, B, C, D depth-first

In this tree, Wrapper is a component, so React looks through it and B counts as first-level. C sits inside the DOM element B, so it is not a direct target. Listeners, observers and rects therefore apply to A, B and D, whereas focus and focusLast search A, B, C and D in depth-first order.

The difference is intentional. Because events bubble, clicks inside C already reach a listener on B, and B's box and observations already encompass C. Focus has no bubbling equivalent, and the element that can take focus may be buried at any depth, so those two methods have to search.

The workarounds this replaces

Before Fragment Refs, there were three common approaches, each with a cost:

  • A wrapper element, <div ref={ref}><Thing /></div>: it inserts a new flex or grid item where a run of siblings used to be, breaks :first-child and :nth-child selectors, adds a layout box, and is outright invalid between table rows or list items.
  • Changing the component to forward its ref or accept an innerRef prop: workable for your own code, impossible for a package, and it still yields only a single node.
  • Querying the DOM from a parent ref, for example parent.querySelectorAll('button') inside an effect: it breaks as soon as children change after the effect ran, leaving a stale set of listeners.

Why this is useful

With Fragment Refs you can add behavior around third-party markup while leaving that markup exactly as it is. That makes a family of behavior-only components natural: <Tracked>, <InView>, <FocusScope>, <Outline>. Each one is a fragment plus an effect, works around arbitrary children and adds nothing to the DOM. Design-system authors benefit most; a Menu component can control focus among items its consumers render, without imposing a structure on those items. The React changelog describes this as composable platform behavior, which is a good mental model: the fragment is where behavior lives, and the children can be whatever they need to be.

Pitfalls to check before relying on it

  • A ref needs the long form; <> will not accept one.
  • Before mount the ref holds null, so every effect should check for it first.
  • Listener, observer and measurement methods look only one DOM level deep, while the two focus methods traverse the whole subtree.
  • scrollIntoView accepts only a boolean. Passing { behavior: 'smooth' } throws. Use scrollIntoView() to align the first child with the top, or scrollIntoView(false) to align the final child with the bottom.
  • Fragments containing only text cannot be observed, because observeUsing targets elements; React warns about this in development.
  • Inside a hidden <Activity> tree, listeners added while hidden are attached only when the subtree becomes visible.
  • Version 19.3 fixed a listener leak (#36047): capture options were not normalised as the DOM spec requires, which meant registering with { capture: true } and unregistering with a bare true left the listener attached. Make sure you are on a fixed version before depending on capture-phase listeners.
  • focus() depends on the window having OS focus. React confirms that an element accepted focus by waiting for its focus event, which Chrome skips when the browser window is not the active one. In headless or backgrounded test runs, focusLast() has been observed to pass over Phone and leave focus on Street, so make sure the test page actually has focus before checking where it landed.
  • A FragmentInstance is not an element, so properties such as classList, style and innerHTML do not exist on it; anything that needs them still calls for a real DOM node.

A fallback for React 19.2 and earlier

For the listener case, the most reasonable substitute is event delegation from a parent ref. Because the handler lives on the parent and checks e.target.closest('button'), children can come and go without any re-attachment:

function Tracked({ children, onAny }) {
  const parent = useRef(null);
  useEffect(() => {
    const el = parent.current;
    const handler = (e) => { if (e.target.closest('button')) onAny(e); };
    el.addEventListener('click', handler);
    return () => el.removeEventListener('click', handler);
  }, [onAny]);
  return <div ref={parent} style={{ display: 'contents' }}>{children}</div>;
}

The display: contents style removes the wrapper's own box, so flex and grid parents treat the children as direct items. It is still an element, though: it cannot legally sit inside a <table>, screen readers and similar tools have at times treated it incorrectly, and measuring or observing the children still requires querySelectorAll. It is adequate for listeners and focus, but the observer and measurement cases really need 19.3.

Key takeaways

  • A ref on an explicit <Fragment> yields a FragmentInstance that groups listening, focus, observation, measurement and scrolling across siblings with no extra DOM node.
  • Listeners and observers stay in sync as children mount and unmount, eliminating manual bookkeeping.
  • Most methods target first-level host children; only focus and focusLast search the whole subtree, because focus does not bubble.
  • Watch the sharp edges: boolean-only scrollIntoView, no text-only observation, window focus in tests and the capture-listener fix in 19.3.
  • Behavior-only wrappers such as tracking, visibility and focus scopes become simple, reusable components.

For the complete method list and caveats, see the official Fragment documentation; the related pull requests appear in the project changelog.