Коротка відповідь. У 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-ів
Якщо у твоєму коді є патерни нижче – вони кандидати на спрощення:
<div style={{ display: 'contents' }}>заради ref – пряма заміна на<Fragment ref>: display:contents якраз і намагався «прибрати» обгортку з лейаута, але лишав вузол у DOM (і має нюанси з доступністю в старих браузерах).- Колекція refs через
useRef(new Map())+ колбек-ref на кожній дитині – якщо все, що ти робив, це фокус/події/observer на групі, Fragment ref робить це без Map. 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 вийшов релізом про «якість життя» – і це приємний тренд.