Références de fragments dans React 19.3 : comportement du DOM pour les groupes de frères, pas de conteneur
Apprenez comment un référent dans Fragment vous fournit une FragmentInstance pour les écouteurs, le focus, les observateurs et les mesures entre éléments frères, ainsi qu’où se situent ses limites.
La plupart des bases de code React contiennent un <div> qui n’existe que pour permettre à quelque chose d’accueillir un ref. Il se peut que le composant dont vous aviez besoin pour effectuer des mesures affiche plusieurs éléments frères, ou qu’il provienne d’une bibliothèque qui ne transmet jamais son ref. Cet élément supplémentaire perturbe alors un layout en grille ou flexible, altère les styles définis avec :nth-child ou, dans le cas des éléments <tr>, génère du HTML invalide. React 19.3 a rendu les Fragment Refs stables : lorsque vous attachez un ref à <Fragment>, vous obtenez une FragmentInstance qui permet d’appliquer des opérations DOM courantes telles que l’écoute, le ciblage, l’observation, les mesures et le défilement sur tous les enfants du fragment en même temps, sans ajouter quoi que ce soit à l’arbre rendu. Cet article aborde l’API, les patterns pratiques pour chacune de ces fonctionnalités, les enfants ciblés par chaque méthode, ainsi que les pièges à connaître avant de livrer le code.
Puisque cette fonctionnalité est nouvelle au moment de la rédaction, consultez la référence actuelle <Fragment> pour connaître d’éventuels changements dans les noms des méthodes ou leur comportement.
L’API FragmentInstance
Fragment, car la référence n’existe qu’après le montage :
import { Fragment, useRef, useEffect } from 'react';
La référence commence par null et devient une FragmentInstance une fois le fragment monté. Le code ci-dessous protège contre la valeur null, ajoute un écouteur et le supprime lors du nettoyage :
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>;
}
Un élément <Fragment> explicite est requis. L’abréviation <>…</> ne prend ni une ref ni une key. Voici ce que l’instance fournit :
addEventListener(type, fn, opts?)etremoveEventListener: s’appliquent aux enfants de premier niveau du DOM, et les écouteurs enregistrés sont transférés aux enfants qui apparaissent ou disparaissent par la suite.dispatchEvent(event): envoie l’événement depuis le fragment lui-même ; avecbubbles: true, l’événement se propage vers le nœud DOM parent.focus(opts?)etfocusLast(opts?): recherchent dans tous les descendants imbriqués, en profondeur, le premier ou le dernier élément pouvant recevoir l’attention, et acceptent{ preventScroll }.blur(): rend flou l’élément actif s’il se trouve à l’intérieur du fragment, sans effet autrement.
observeUsing(observer) et unobserveUsing(observer) : enregistrent les enfants de premier niveau du DOM avec un IntersectionObserver ou un ResizeObserver ; les nœuds de texte ne sont pas pris en compte.getClientRects() : retourne un tableau plat DOMRect[] contenant les informations des enfants de premier niveau du DOM.scrollIntoView(alignToTop?) : fait défiler vers la vue le premier enfant si la valeur est true ou le dernier enfant si elle est false ; seule une valeur booléenne est acceptée, toute autre entrée provoque une erreur.getRootNode() et compareDocumentPosition(node) : décrivent la position du fragment selon les mêmes conventions que les méthodes Node.Un écouteur pour un groupe d’enfants
Un scénario courant concerne une barre d’outils affichée par un composant que vous ne contrôlez pas, et pour laquelle vous souhaitez un écouteur de clic unique pour tous ses boutons, par exemple afin d’envoyer des données analytiques. La démo importe :
import { Fragment, useRef, useEffect, useState } from 'react';
Toolbar représente un composant de tiers : il affiche des boutons simples sans enveloppe ni référence, ainsi qu’un quatrième bouton facultatif. Tracked enrobe ses enfants dans un Fragment référencé et assigne le gestionnaire onAny dans un effet, en le réassignant lorsque ce gestionnaire change. App enregistre le texte de chaque bouton cliqué et propose une case à cocher pour ajouter le bouton supplémentaire :
// 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>
</>
);
}
Les styles servent uniquement à mettre en forme la barre d’outils et la ligne de journalisation :
.bar { display: flex; gap: 8px; margin-bottom: 10px; }
.log { margin: 10px 0 0; font-family: ui-monospace, monospace; opacity: .8; }
Activez le quatrième bouton et cliquez dessus ; son action sera enregistrée comme celle des autres. L’instance du fragment suive les éléments enfants lorsque React les monte ou les démonte, et met à jour ses écouteurs en conséquence, ce qui correspond exactement au suivi manuel que vous devriez effectuer vous-même. Notez que onAny est une flèche intégrée dans la démonstration, ce qui fait que l’effet se réinscrit à chaque rendu de App ; dans du code réel, stabilisez-le avec useCallback si ce type de réinscription est problématique.
Déplacement du focus à travers du contenu imbriqué
Seuls focus() et focusLast() examinent les éléments au-delà du premier niveau. Ils parcourent tous les descendants dans un ordre en profondeur jusqu’à trouver un élément sur lequel appliquer le focus, de sorte qu’un champ d’entrée situé à l’intérieur d’une étiquette, elle-même insérée dans un fieldset, reste accessible. Cela rend la tâche « faire focus sur le premier champ de cette section » très simple, même pour du markup qui ne vous appartient pas.
La démo importe uniquement Fragment et useRef:
import { Fragment, useRef } from 'react';
Section affiche un titre et trois boutons, connectés à focus(), focusLast() et blur() sur l’instance du fragment, puis affiche ses enfants à l’intérieur du Fragment référencé par useRef. App transmet deux fieldsets contenant des champs étiquetés :
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>
);
}
Un anneau de focus rend le résultat 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); }
« Premier champ » se réfère à Street, bien qu’il se trouve à l’intérieur d’un <label> au sein d’un <fieldset>, et « Dernier champ » se réfère à Phone. Cette recherche en profondeur diffère des événements et observateurs, qui s’arrêtent au premier niveau ; la section sur le ciblage ci-dessous explique pourquoi.
Vérifier la visibilité sans perturber le layout
observeUsing() accepte un observateur que vous créez vous-même, ce qui vous permet de garder le contrôle sur root, threshold et rootMargin ; l’instance enregistre alors chaque enfant de premier niveau. L’exemple suivant marque les derniers messages de chat comme déjà consultés dès qu’un d’eux devient visible dans la conversation scrollable. Un élément conteneur autour de ces messages aurait perturbé le layout flex du panneau, où chaque bulle s’aligne indépendamment.
Les imports incluent useLayoutEffect afin que l’observateur soit enregistré avant le rendu :
import { Fragment, useRef, useLayoutEffect, useState } from 'react';
ReadReceipt crée un IntersectionObserver ancré dans la zone de défilement avec un seuil de 0,6, appelle onRead lorsque n’importe quelle entrée entre en intersection, et nettoie tout avec unobserveUsing. App affiche quatorze messages, en plaçant les trois derniers dans 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>
</>
);
}
.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 fonctionne de manière identique. Si vous gérez de nombreux groupes qui partagent les mêmes options d’observateur, vous n’avez pas besoin d’un observateur par groupe : chaque enfant de premier niveau possède une propriété reactFragments contenant un Set des instances de fragments auxquelles il appartient, ce qui permet à un seul observateur de trouver le groupe propriétaire pour chaque entrée et d’appeler le bon gestionnaire.
Mesure d’un groupe de frères et sœurs
getClientRects() renvoie un rectangle pour chaque enfant de premier niveau. En les combinant, on obtient la boîte délimitante de l’ensemble du groupe, que l’on peut utiliser pour dessiner un encadrement de sélection ou des crochets autour de plusieurs champs invalides sans modifier la disposition dans laquelle se trouvent ces enfants.
La démonstration importe :
import { Fragment, useRef, useState, useLayoutEffect } from 'react';
Outline lit les rectangles d’un effet de disposition dès qu’il devient actif, calcule la valeur minimale pour le haut et la gauche ainsi que la valeur maximale pour le bas et la droite, ajoute des offsets de défilement ainsi qu’un léger espacement, puis enregistre le résultat. Il rend ensuite un contour positionné absolument à côté du fragment. App place deux groupes de trois champs dans la même grille et permet de choisir celui à contourner :
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>
</>
);
}
Le grille comporte trois colonnes, et le contour est positionné absolument avec pointer-events: none de sorte qu’il ne bloque jamais les clics :
.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; }
Chaque champ d’entrée ici est un enfant direct de la même grille CSS. En enveloppant chaque groupe dans un <div>, ses trois champs d’entrée seraient compressés dans une seule cellule ; avec un fragment, la grille reste inchangée et chaque groupe peut toujours être mesuré. Une limitation de la démonstration est que le calcul du box n’a lieu qu’en cas de modification de active, ce qui fait que la taille du box reste obsolète en cas de redimensionnement de fenêtre ou de déplacement d’un conteneur de scroll. L’utilisation d’un ResizeObserver via observeUsing résout ce problème.
Quels enfants chaque méthode cible
La plupart des méthodes fonctionnent sur ce que React appelle les enfants hôtes de premier niveau, c’est-à-dire les éléments DOM auxquels on accède en descendant à travers des composants React jusqu’à atteindre un élément réel. Les éléments imbriqués à l’intérieur ne sont pas ciblés directement.
<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
Dans cet arbre, Wrapper est un composant, donc React le parcourt et B est considéré comme étant de premier niveau. C se trouve à l’intérieur de l’élément DOM B, il n’est donc pas un cible directe. Les écouteurs, observateurs et rectangles s’appliquent donc à A, B et D, tandis que focus et focusLast recherchent dans l’ordre profondeur-première A, B, C et D.
La différence est intentionnelle. Comme les événements remontent en cascade, les clics à l’intérieur de C atteignent déjà un écouteur sur B, et la boîte de mise en page ainsi que les observations de B englobent déjà C. Le focus ne dispose pas d’équivalent en termes de remontée en cascade, et l’élément qui peut recevoir le focus peut se trouver à n’importe quelle profondeur, ce qui oblige ces deux méthodes à effectuer des recherches.
Les solutions de contournement qu’il remplace
Au préalable aux Fragment Refs, il existait trois approches courantes, chacune ayant un coût :
- Un élément conteneur,
<div ref={ref}><Thing /></div>: il insère un nouvel élément flex ou grid à l’endroit où se trouvaient auparavant des frères d’éléments, brise les sélecteurs:first-childet:nth-child, ajoute une boîte de mise en page, et est purement invalide entre des lignes de tableau ou des éléments de liste.
innerRef : cela fonctionne pour son propre code, mais est impossible pour un package, et cela ne produit toujours qu’un seul nœud.parent.querySelectorAll('button') à l’intérieur d’un effet : cela ne fonctionne plus dès que les enfants changent après exécution de l’effet, laissant un ensemble de écouteurs obsolètes.Pourquoi c’est utile
Avec les fragments de référence, vous pouvez ajouter du comportement autour de marquages tiers tout en laissant ces marquages exactement tels qu’ils sont. Cela rend naturelle l’utilisation d’une famille de composants ne contenant que du comportement : <Tracked>, <InView>, <FocusScope>, <Outline>. Chacun d’eux est un fragment associé à un effet, fonctionne avec n’importe quels enfants et ne modifie en rien le DOM. Les concepteurs de systèmes de design en bénéficient le plus ; un composant Menu peut gérer le focus entre les éléments affichés par ses utilisateurs, sans imposer de structure à ces éléments. Le journal des modifications de React décrit cela comme un comportement de plateforme composable, ce qui constitue un bon modèle mental : le fragment est l’endroit où réside le comportement, tandis que les enfants peuvent être ce qu’ils doivent être.
Périls à vérifier avant de s’y fier
- <> ne l’acceptera pas.
null, donc chaque effet doit d’abord vérifier cette valeur.scrollIntoView ne accepte qu’un booléen. L’envoi de { behavior: 'smooth' } provoque une erreur. Utilisez scrollIntoView() pour aligner le premier enfant en haut, ou scrollIntoView(false) pour aligner le dernier enfant en bas.observeUsing cible des éléments ; React émet une alerte à ce sujet en mode développement.<Activity> caché, les écouteurs ajoutés pendant le cachement ne sont attachés qu’une fois que le sous-arbre devient visible.#36047) : les options de capture n’étaient pas normalisées comme le prévoit la spécification DOM, ce qui signifiait que l’enregistrement avec { capture: true } et le désenregistrement avec simplement true laissaient l’écouteur activé. Assurez-vous d’utiliser une version stable avant de compter sur les écouteurs de phase de capture.focus() dépend du fait que la fenêtre ait le focus système. React confirme qu’un élément accepte le focus en attendant son événement focus, que Chrome saute lorsque la fenêtre du navigateur n’est pas active. Lors de tests sans interface graphique ou en arrière-plan, on a observé que focusLast() ignorait Phone et laissait le focus sur Street ; assurez-vous donc que la page de test a réellement le focus avant de vérifier où il se trouve.FragmentInstance n’est pas un élément, donc des propriétés telles que classList, style et innerHTML n’existent pas sur elle ; tout ce qui a besoin d’elles requiert néanmoins un véritable nœud DOM.Solution de secours pour React 19.2 et versions antérieures
Pour le cas des écouteurs, le remplaçant le plus logique est la délégation d’événements depuis un parent ref. Comme le gestionnaire se trouve sur le parent et vérifie e.target.closest('button'), les enfants peuvent apparaître ou disparaître sans qu’il soit nécessaire de les réassocier à nouveau :
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>;
}
La propriété display: contents supprime le boîtier propre au conteneur, de sorte que les parents flex et grid traitent les enfants comme des éléments directs. Il s’agit néanmoins toujours d’un élément : il ne peut pas légalement se trouver à l’intérieur d’un <table>, les lecteurs d’écran et outils similaires l’ont parfois traité de manière incorrecte, et la mesure ou l’observation des enfants nécessite encore querySelectorAll. Il convient pour les écouteurs et le focus, mais les cas d’observation et de mesure exigent réellement la version 19.3.
Points clés
- Une propriété
refsur un<Fragment>explicite génère uneFragmentInstancequi regroupe les écouteurs, le focus, l’observation, la mesure et le défilement entre frères sans créer de nœud DOM supplémentaire. - Les écouteurs et les observateurs restent synchronisés lorsque les enfants sont ajoutés ou supprimés, éliminant ainsi le besoin de suivi manuel.
focus et focusLast recherchent toute la sous-arborescence, car focus ne remonte pas les niveaux.scrollIntoView ne prend que des valeurs booléennes, il n’y a pas d’observation basée uniquement sur du texte, le focus de la fenêtre est pris en compte dans les tests, ainsi que la correction du capture-listener dans la version 19.3.Pour la liste complète des méthodes et leurs particularités, consultez la documentation officielle de Fragment ; les demandes de mise à jour associées figurent dans le journal des changements du projet.