Skip to content
back to blog
developmentOctober 19, 2026 · 4 min read

React Fragment Refs: Attach Behavior Without the Wrapper Div

React 19.3 made Fragment refs stable. Here's how FragmentInstance lets you listen, observe, and move focus across a group of elements without adding a wrapper div or touching child components.

Dan Holloran
Dan Holloran
Senior Frontend & Fullstack Developer
React Fragment Refs: Attach Behavior Without the Wrapper Div

Every React codebase has at least one of these: a <div> that exists for no reason except to hold a ref. Maybe it wraps a list so an IntersectionObserver has something to watch. Maybe it wraps a group of form fields so you can focus the first one. It works, right up until it breaks a flex layout, adds a stray grid item, or lands inside a <ul> where only <li> belongs.

The other version of this problem is worse. You want to observe or focus a component from a library, and that component doesn't forward its ref. Now you're either forking the library or adding the wrapper div anyway.

React 19.3, released on September 9, 2026, made Fragment refs stable. You can now pass a ref to <Fragment> and get back a FragmentInstance, an object with a small set of DOM methods that operate on the fragment's children as a group. No wrapper, no changes to the children.

What a FragmentInstance can do ​

The setup is the ref you already know, just on a Fragment. One catch: the <>...</> shorthand can't take props, so you need the explicit Fragment import.

jsx
import { Fragment, useRef } from "react";

function Toolbar({ children }) {
  const groupRef = useRef(null);
  return <Fragment ref={groupRef}>{children}</Fragment>;
}

groupRef.current is now a FragmentInstance. Its methods fall into four groups:

  • Events: addEventListener, removeEventListener, and dispatchEvent
  • Focus: focus, focusLast, and blur
  • Observers: observeUsing and unobserveUsing, which take an IntersectionObserver or ResizeObserver
  • Layout: getClientRects, getRootNode, compareDocumentPosition, and scrollIntoView

The important detail is which nodes these touch. Event, observer, and layout methods target the fragment's first-level DOM children. React looks through component boundaries to find them, so if a child component renders a <section>, that <section> is a target. But it stops at the first real DOM node: an element nested inside that <section> is not. Focus methods are the exception. focus() and focusLast() search depth-first through everything, which is what you want when the inputs are buried inside a <fieldset> and a <label>.

Listeners are also kept in sync. If children are added or removed later, the FragmentInstance attaches or detaches the listener for you.

Observing visibility with no wrapper and no forwarded refs ​

This is the case that used to force the wrapper div. Here is a reusable InView component that reports whether any of its children are on screen:

jsx
import { Fragment, useLayoutEffect, useRef } from "react";

export function InView({ onChange, children }) {
  const fragmentRef = useRef(null);

  useLayoutEffect(() => {
    const visible = new Set();
    const observer = new IntersectionObserver((entries) => {
      for (const entry of entries) {
        if (entry.isIntersecting) visible.add(entry.target);
        else visible.delete(entry.target);
      }
      onChange(visible.size > 0);
    });

    const fragment = fragmentRef.current;
    fragment.observeUsing(observer);
    return () => fragment.unobserveUsing(observer);
  }, [onChange]);

  return <Fragment ref={fragmentRef}>{children}</Fragment>;
}

Use it around anything, including third-party components that never heard of refs:

jsx
<InView onChange={setHeroVisible}>
  <VendorBanner />
  <PricingTable />
</InView>

VendorBanner and PricingTable render exactly the DOM they always did. Your CSS grid still sees two items, not one wrapper. If onChange is an inline function, wrap it in useCallback (or let the React Compiler handle it) so the observer isn't torn down on every render.

One warning from the docs: observeUsing doesn't work on text nodes, and React logs a dev warning if a fragment contains only text.

Focus management for groups ​

Focus is the other place this pays off, and it's an accessibility win. When a disclosure panel opens, a dialog-like section mounts, or a validation error appears, you usually want to move focus into it. Before, that meant a ref on the specific input, which meant threading refs through whatever components sat in between.

jsx
import { Fragment, useEffect, useRef } from "react";

function StepPanel({ isActive, children }) {
  const stepRef = useRef(null);

  useEffect(() => {
    if (isActive) stepRef.current.focus({ preventScroll: true });
  }, [isActive]);

  return <Fragment ref={stepRef}>{children}</Fragment>;
}

focus() finds the first focusable element anywhere inside the children, and focusLast() finds the last one. That pair is most of what you need for a simple focus loop at the edges of a section. blur() only does something if the active element is actually inside the fragment, so it's safe to call without checking first.

Two small gotchas. scrollIntoView takes only a boolean (true aligns the first child to the top, false aligns the last child to the bottom), and passing a ScrollIntoViewOptions object throws. And listeners added through addEventListener are not applied to children inside a hidden <Activity> boundary until it becomes visible again.

When to reach for it ​

Fragment refs aren't a replacement for regular refs. If you own the element and it's a single node, a plain ref on that element is still simpler. Reach for a Fragment ref when you need to attach behavior to a group of siblings, or to components you don't control, and a wrapper element would cost you layout, semantics, or both.

If you're already on React 19.3, try deleting one wrapper div this week. The <Fragment> reference covers every method, and the React 19.3 release post walks through View Transitions and the new browser() API that shipped alongside it.

~/subscribe
# new posts on code, craft & travel — no noise, no schedule
$subscribe