Коротка відповідь. У React 19.3 <Fragment> приймає ref і повертає FragmentInstance – об'єкт, що працює з DOM-вузлами дітей фрагмента як із групою: focus(), addEventListener(), observeUsing(observer), getClientRects() – без зміни структури DOM. Це закриває давню дірку: щоб керувати групою сусідніх елементів, більше не треба загортати їх у зайвий div чи вручну збирати refs з кожної дитини. Нижче – що саме вміє API, повний приклад accessible toolbar і чесний список ситуацій, де Fragment ref не потрібен.

Проблема зайвого wrapper-div

Класична ситуація: компонент рендерить групу сусідніх елементів, а тобі потрібно щось зробити з ними як із цілим – перевести фокус, повісити обробник, виміряти позицію.

До 19.3 варіантів було три, і всі з компромісами:

  • Wrapper-div. Працює, але ламає розмітку: зайвий вузол у flex/grid-контейнері змінює поведінку лейаута, а в списках і таблицях – ще й семантику для скрінрідерів.
  • Ref на кожну дитину. Шум у коді, а якщо діти – чужі компоненти, які не прокидають ref, то й глухий кут.
  • Обхід DOM руками через parentElement/querySelectorAll. Крихко: React нічого не гарантує про сусідів поза своїм деревом.

Fragment Refs – четвертий варіант без цих компромісів.

Що дає ref на Fragment

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

function PostHeadings({ posts }: { posts: Post[] }) {
  const fragmentRef = useRef<FragmentInstance | null>(null);

  useEffect(() => {
    // Фокус піде по дітях фрагмента depth-first
    fragmentRef.current?.focus();
  }, []);

  return (
    <Fragment ref={fragmentRef}>
      {posts.map((post) => (
        <Heading key={post.id}>{post.title}</Heading>
      ))}
    </Fragment>
  );
}

FragmentInstance оперує DOM-ом дітей як групою, не змінюючи структуру. За офіційним анонсом React 19.3, API складається з трьох блоків:

  • Події: addEventListener(), removeEventListener(), dispatchEvent() – для first-level дітей фрагмента.
  • Фокус: focus() (depth-first по вкладених дітях), focusLast(), blur().
  • Спостереження і вимірювання: observeUsing(observer) / unobserveUsing(observer) для IntersectionObserver або ResizeObserver, getClientRects(), scrollIntoView(), getRootNode(), compareDocumentPosition().

Ключове обмеження, про яке анонс каже прямо: інструменти працюють із first-level дітьми фрагмента. Це група сусідів, а не рекурсивний контроль над усім піддеревом.

Приклад: toolbar без зайвого div

Тулбар за ARIA-патерном має одну тонкість: при поверненні фокуса в тулбар фокус має отримати кнопка, а сам контейнер – ні. Коли кнопки рендеряться різними компонентами, раніше доводилось або загортати все в div і шукати кнопки querySelector-ом, або тягнути ref у кожен компонент.

З Fragment ref група лишається «пласкою» – кнопки живуть прямо у flex-контейнері сторінки:

import { Fragment, useRef } from 'react';

function EditorToolbar() {
  const itemsRef = useRef<FragmentInstance | null>(null);

  // Користувач повертається в тулбар хоткеєм – фокусуємо
  // першу кнопку групи, не знаючи, який компонент її рендерить
  function focusToolbar() {
    itemsRef.current?.focus();
  }

  return (
    <div role="toolbar" aria-label="Форматування" onKeyDown={handleArrows}>
      <Fragment ref={itemsRef}>
        <BoldButton />
        <ItalicButton />
        <HistoryControls /> {/* рендерить дві кнопки – і це ок */}
      </Fragment>
    </div>
  );
}

BoldButton та HistoryControls не мусять прокидати ref – фрагмент бачить їхні DOM-вузли як своїх дітей. Логіку стрілок вліво/вправо (roving tabindex) усе ще пишеш сам – Fragment ref дає тобі групу вузлів, а не готовий behaviour.

Другий кейс – прямо з доків React: компонент InView, що повідомляє про видимість групи дітей, підключаючи IntersectionObserver через observeUsing:

export function InView({ onChange, children }: InViewProps) {
  const fragmentRef = useRef<FragmentInstance | null>(null);

  useLayoutEffect(() => {
    const visible = new Set<Element>();
    const observer = new IntersectionObserver((entries) => {
      for (const entry of entries) {
        entry.isIntersecting
          ? visible.add(entry.target)
          : visible.delete(entry.target);
      }
      onChange(visible.size > 0);
    });
    const instance = fragmentRef.current;
    instance?.observeUsing(observer);
    return () => instance?.unobserveUsing(observer);
  }, [onChange]);

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

Аналітика видимості секцій, lazy-ефекти, «прочитаність» блоків статті – усе без обгортки, яка в grid-розмітці зламала б колонки.

Де Fragment ref не потрібен

Новий API – не привід викидати звичайні refs:

  • Один елемент – один ref. Якщо ціль одна, useRef на елемент простіший і прозоріший.
  • Потрібен контейнер семантично. Для role="toolbar", role="listbox" чи скрол-контейнера однаково потрібен реальний DOM-вузол – фрагмент його не замінює, він лише прибирає зайві обгортки.
  • Глибокий контроль піддерева. Fragment ref працює з first-level дітьми; для вкладених структур потрібна інша архітектура (контекст, композиція).
  • Стилізація. Фрагменту не можна дати клас чи стиль – це досі про DOM-вузли.

Міграція з workaround-ів

Якщо у твоєму коді є патерни нижче – вони кандидати на спрощення:

  1. <div style={{ display: 'contents' }}> заради ref – пряма заміна на <Fragment ref>: display:contents якраз і намагався «прибрати» обгортку з лейаута, але лишав вузол у DOM (і має нюанси з доступністю в старих браузерах).
  2. Колекція refs через useRef(new Map()) + колбек-ref на кожній дитині – якщо все, що ти робив, це фокус/події/observer на групі, Fragment ref робить це без Map.
  3. containerRef.current.querySelectorAll('button') – крихкий обхід замінюється на addEventListener/focus рівня групи.

Тестування: поведінка лишається DOM-поведінкою, тож тести «як користувач» не зміняться – фокус і події перевіряються так само, як і до міграції. Це хороший маркер, що ти тестував поведінку, а не implementation details.

Типові помилки

  • Пхати Fragment ref усюди. Якщо ref-а раніше не було і все працювало – він не потрібен і зараз.
  • Чекати рекурсії. Методи працюють по first-level дітях; вкладені фрагменти – окремі інстанси.
  • Забувати cleanup для observer-ів. unobserveUsing у cleanup-функції ефекту обов'язковий, як і зі звичайним observer-ом.
  • Керувати фокусом без потреби. Автофокус групи при кожному рендері – шлях до роздратованих користувачів клавіатури. Фокус переводять у відповідь на дію користувача; детальніше – у чеклісті доступності.

Практичне завдання

Візьми будь-який свій компонент, де є wrapper-div лише заради ref (перевір: чи має він клас/роль/стилі? Якщо ні – він «технічний»). Перепиши на <Fragment ref>: (1) фокус першої дитини по хоткею, (2) IntersectionObserver через observeUsing з логом видимості. Запусти і порівняй DOM у девтулзах до/після – зайвий вузол має зникнути, а flex/grid-розмітка стати простішою.

Fragment Refs – з тих API, які не міняють архітектуру, але прибирають дрібне тертя, що накопичувалось роками. Разом із View Transitions React 19.3 вийшов релізом про «якість життя» – і це приємний тренд.