Referencias de fragmentos en React 19.3: Comportamiento del DOM para grupos de elementos hermanos, sin contenedor
Aprenda cómo un referente en Fragment le proporciona una FragmentInstance para los oyentes, el foco, los observadores y las mediciones en elementos hermanos, así como cuáles son sus limitaciones.
La mayoría de los repositorios de código React contienen un <div> que existe únicamente para que algo pueda albergar un ref. Quizás el componente que necesitabas medir mostraba varios elementos hermanos, o provenía de una biblioteca que nunca reenviaba su ref. Ese elemento adicional rompe un diseño basado en cuadrícula o flex, perturba los estilos definidos con :nth-child o, en el caso de elementos <tr>, genera HTML inválido. React 19.3 establece como estáticos los Fragment Refs: cuando asignas un ref a <Fragment>, recibes una FragmentInstance que permite aplicar operaciones comunes del DOM como escuchar eventos, centrar el foco, observar cambios, medir y desplazarse por todos los hijos del fragmento al mismo tiempo, sin añadir nada al árbol renderizado. Este artículo aborda la API, los patrones prácticos para cada funcionalidad, qué hijos afecta cada método y las trampas que deben conocerse antes de publicar el código.
Dado que esta función es nueva en el momento de redactar este texto, consulte la referencia actual de <Fragment> para conocer cualquier cambio en los nombres de los métodos o en su comportamiento.
La API FragmentInstance
Una configuración típica importa explícitamente Fragment, ya que la referencia solo existe después del montaje:
import { Fragment, useRef, useEffect } from 'react';
La referencia comienza como null y se convierte en una FragmentInstance una vez que el fragmento se monta. El código a continuación protege contra valores null, asigna un oyente y lo elimina al realizar la limpieza:
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>;
}
Se requiere el elemento explícito <Fragment>. La abreviatura <>…</> no acepta ni una ref ni una key. A continuación se muestra qué proporciona la instancia:
addEventListener(type, fn, opts?)yremoveEventListener: operan en los hijos de primer nivel del DOM, y los listeners registrados se transfieren a los hijos que se añadan o eliminen posteriormente.dispatchEvent(event): envía el evento en el propio fragmento; conbubbles: trueel evento se propaga al nodo DOM padre.focus(opts?)yfocusLast(opts?): buscan en todos los descendientes anidados de forma profunda hacia adentro el primer o último elemento en el que se pueda centrar la atención, y aceptan{ preventScroll }.blur(): desenfoca el elemento activo si se encuentra dentro del fragmento, y no hace nada en otros casos.
observeUsing(observer) y unobserveUsing(observer): registra a los hijos de primer nivel del DOM con un IntersectionObserver o ResizeObserver; los nodos de texto no están incluidos.getClientRects(): devuelve un array plano DOMRect[] para los hijos de primer nivel del DOM.scrollIntoView(alignToTop?): desplaza al primer hijo a la vista si el valor es true o al último hijo si es false; solo se acepta un valor booleano, y cualquier objeto de opciones genera un error.getRootNode() y compareDocumentPosition(node): describen la posición del fragmento siguiendo los mismos criterios que los métodos de Node.Un único listener para un grupo de hijos
Un escenario común es una barra de herramientas generada por un componente que no controlas, donde deseas tener un único receptor de clics para todos sus botones, por ejemplo, para enviar datos a los sistemas de análisis. La demostración importa:
import { Fragment, useRef, useEffect, useState } from 'react';
Toolbar representa un componente de terceros: muestra botones hermanos sin envoltorio ni referencia, además de un cuarto botón opcional. Tracked envuelve a sus hijos en un Fragment con referencia y asigna el manejador onAny en un efecto, volviendo a asignarlo cuando el manejador cambia. App registra el texto de cada botón en el que se hace clic y ofrece una casilla de verificación para agregar el botón adicional:
// 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>
</>
);
}
Los estilos solo se encargan de dar formato a la barra de herramientas y a la línea de registro:
.bar { display: flex; gap: 8px; margin-bottom: 10px; }
.log { margin: 10px 0 0; font-family: ui-monospace, monospace; opacity: .8; }
Active el cuarto botón y hágalo clic; se registrará de la misma manera que los demás. La instancia del fragmento hace un seguimiento de sus hijos a medida que React los monta y desmonta, y actualiza sus listeners en consecuencia, lo cual es exactamente el control que de otro modo tendría que realizar usted mismo. Tenga en cuenta que onAny es una función de flecha inline en la demostración, por lo que el efecto se vuelve a suscribir en cada renderización de App; en código real, estabilícelo con useCallback si ese cambio frecuente es importante.
Mover el foco a través de contenido anidado
Solo focus() y focusLast() buscan más allá del primer nivel. Recorren cada descendiente en orden de profundidad hasta encontrar un elemento en el que se pueda colocar el foco, por lo que un campo de entrada oculto dentro de una etiqueta dentro de un fieldset sigue siendo accesible. Eso hace que “colocar el foco en el primer campo de esta sección” sea algo sencillo incluso con marcado que no le pertenece.
La demostración importa únicamente Fragment y useRef:
import { Fragment, useRef } from 'react';
Section muestra un título y tres botones, conectados a focus(), focusLast() y blur() en la instancia del fragmento, y luego renderiza sus hijos dentro del Fragment al que se hace referencia con ref. App pasa dos fieldsets que contienen campos de entrada etiquetados:
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 anillo de enfoque hace que el resultado sea 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); }
“Primer campo” se selecciona en Street, aunque se encuentra dentro de un <label> dentro de un <fieldset>, y “Último campo” se selecciona en Phone. Esta búsqueda en profundidad es diferente de los eventos y observadores, que se detienen en el primer nivel; la sección sobre orientación a continuación explica por qué.
Monitorear la visibilidad sin alterar el diseño
observeUsing() acepta un observador que usted mismo crea, de modo que mantiene el control sobre root, threshold y rootMargin; además, la instancia registra a cada hijo de primer nivel con él. El siguiente ejemplo marca como vistos los últimos mensajes de chat en cuanto uno de ellos se vuelve visible dentro de la conversación desplazable. Un elemento contenedor alrededor de esos mensajes habría alterado el diseño flexible del panel, donde cada mensaje se alinea de forma independiente.
Las importaciones incluyen useLayoutEffect para que el observador se registre antes de la pintura:
import { Fragment, useRef, useLayoutEffect, useState } from 'react';
ReadReceipt crea un IntersectionObserver con origen en el panel de desplazamiento y umbral 0.6, llama a onRead cuando alguna entrada intersecta y limpia el observador con unobserveUsing. App muestra catorce mensajes, encerrando los tres últimos dentro de 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>
</>
);
}
El CSS convierte el panel en una columna flexible con desplazamiento y alinea los mensajes del usuario hacia la derecha:
.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 funciona de manera idéntica. Si gestionas muchos grupos que comparten las mismas opciones de observador, no necesitas un observador por grupo: cada hijo de primer nivel posee una propiedad reactFragments que contiene un Set con las instancias de fragmento a las que pertenece, lo que permite que un único observador encuentre el grupo correspondiente para cada entrada y llame al manejador adecuado.
Medición de un grupo de hermanos
getClientRects() devuelve un rectángulo por cada hijo de primer nivel. Al combinarlos se obtiene el cuadro delimitador de todo el grupo, que se puede utilizar para dibujar un resaltado de selección o unos corchetes alrededor de varios campos inválidos sin afectar al diseño en el que se encuentran los hijos.
La demostración importa:
import { Fragment, useRef, useState, useLayoutEffect } from 'react';
Outline lee los rectángulos en un efecto de diseño cuando este se activa, calcula el valor mínimo de arriba e izquierda y el máximo de abajo y derecha, agrega desplazamientos de desplazamiento vertical e horizontal además de un pequeño margen, y almacena el resultado. Luego renderiza un contorno posicionado absolutamente junto al fragmento. App coloca dos grupos de tres campos de entrada en la misma cuadrícula y permite elegir cuál se va a delimitar con un contorno:
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>
</>
);
}
La cuadrícula tiene tres columnas, y el contorno está posicionado absolutamente con pointer-events: none para que nunca intercepte los 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; }
Cada elemento de entrada aquí es un hijo directo de la misma cuadrícula CSS. Si se envuelve cada grupo en un <div>, sus tres elementos de entrada quedarían comprimidos en una sola celda; con un fragmento, la cuadrícula no se ve afectada y cada grupo sigue pudiendo medirse. Una limitación de la demostración es que el cálculo del contenedor solo se realiza cuando cambia el estado active, por lo que un cambio de tamaño de ventana o un contenedor de desplazamiento en movimiento haría que el cálculo quedara obsoleto. Combinarlo con un ResizeObserver mediante observeUsing soluciona este problema.
Qué hijos abarca cada método
La mayoría de los métodos operan sobre lo que React denomina hijos anfitriones de primer nivel, es decir, los elementos DOM a los que se accede descendiendo a través de componentes React hasta encontrar un elemento real. Los elementos anidados dentro de ellos no se targetan directamente.
<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
En este árbol, Wrapper es un componente, por lo que React lo examina y B se considera de primer nivel. C se encuentra dentro del elemento DOM B, por lo que no es un objetivo directo. Por lo tanto, los listeners, observadores y rects se aplican a A, B y D, mientras que focus y focusLast buscan en A, B, C y D en orden de profundidad primero.
La diferencia es intencional. Debido al mecanismo de propagación de eventos, los clics dentro de C ya llegan a un receptor en B, y el contenedor y las observaciones de B ya abarcan a C. El foco no tiene un equivalente en este proceso de propagación, y el elemento que puede recibir el foco puede estar oculto a cualquier profundidad, por lo que esos dos métodos deben buscar activamente.
Las soluciones alternativas que reemplaza
Antes de los Fragment Refs, existían tres enfoques comunes, cada uno con sus desventajas:
- Un elemento contenedor,
<div ref={ref}><Thing /></div>: inserta un nuevo elemento flex o grid donde antes había una secuencia de elementos hermanos, interrumpe los selectores:first-childy:nth-child, agrega un contenedor de diseño, y es inválido cuando se utiliza entre filas de tabla o elementos de lista.
innerRef: funcional para tu propio código, imposible para un paquete, y aún así solo genera un único nodo.parent.querySelectorAll('button') dentro de un efecto: deja de funcionar en cuanto los hijos cambian después de que se ejecutó el efecto, dejando un conjunto obsoleto de listeners.Por qué esto es útil
Con los Fragment Refs puedes añadir comportamientos alrededor de marcado de terceros manteniendo ese marcado exactamente como está. Eso hace que sea natural contar con una familia de componentes compuestos únicamente por comportamientos: <Tracked>, <InView>, <FocusScope>, <Outline>. Cada uno consiste en un fragmento más un efecto, funciona con hijos arbitrarios y no agrega nada al DOM. Los creadores de sistemas de diseño se benefician al máximo; un componente Menu puede controlar el foco entre los elementos que sus usuarios renderizan, sin imponer una estructura específica a dichos elementos. El registro de cambios de React lo describe como comportamiento de plataforma componible, lo cual es un buen modelo mental: el fragmento es donde reside el comportamiento, y los hijos pueden ser lo que sea necesario.
Riesgos a verificar antes de confiar en ellos
- Un ref necesita la forma extendida;
<>no la aceptará.
null, por lo que cada efecto debe verificarlo primero.scrollIntoView solo acepta un valor booleano. Pasar { behavior: 'smooth' } genera un error. Utilice scrollIntoView() para alinear el primer hijo en la parte superior, o scrollIntoView(false) para alinear el último hijo en la parte inferior.observeUsing está dirigido a elementos; React emite una advertencia al respecto durante el desarrollo.<Activity> oculto, los listeners añadidos mientras está oculto se adjuntan solo cuando el subárbol se vuelve visible.#36047): las opciones de captura no se normalizaban como exige la especificación del DOM, lo que significaba que registrar con { capture: true } y des Registrar con un simple true dejaba el listener activo. Asegúrese de estar utilizando una versión estable antes de depender de los listeners de fase de captura.focus() depende de que la ventana tenga el foco del sistema operativo. React confirma que un elemento acepta el foco al esperar su evento focus, el cual Chrome lo omite cuando la ventana del navegador no es la activa. En pruebas sin interfaz gráfica o en segundo plano, se ha observado que focusLast() pasa por encima de Phone y deja el foco en Street; por lo tanto, asegúrese de que la página de prueba realmente tenga el foco antes de verificar dónde se ha posado.FragmentInstance no es un elemento, por lo que propiedades como classList, style y innerHTML no existen en él; cualquier cosa que las necesite sigue requiriendo un nodo DOM real.Solución alternativa para React 19.2 y versiones anteriores
En el caso de los listeners, el sustituto más razonable es la delegación de eventos desde un ref padre. Dado que el manejador se encuentra en el padre y verifica e.target.closest('button'), los hijos pueden aparecer y desaparecer sin necesidad de volver a asociarlos:
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>;
}
El estilo display: contents elimina el contenedor propio, por lo que los padres de tipo flex y grid tratan a los hijos como elementos directos. Sin embargo, sigue siendo un elemento: no puede colocarse legalmente dentro de un <table>, los lectores de pantalla y herramientas similares a veces lo han tratado incorrectamente, y para medir u observar a los hijos sigue siendo necesario utilizar querySelectorAll. Es adecuado para los listeners y el foco, pero en los casos de observación y medición realmente se necesita la versión 19.3.
Puntos clave
- Un
refen un<Fragment>explícito genera unaFragmentInstanceque agrupa las funciones de listening, foco, observación, medición y desplazamiento entre hermanos sin crear un nodo DOM adicional. - Los listeners y observadores permanecen sincronizados a medida que los hijos se añaden o eliminan, lo que evita la necesidad de un registro manual.
focus y focusLast buscan en todo el subárbol, ya que focus no propaga su efecto hacia arriba.scrollIntoView funciona únicamente con valores booleanos, no hay observación solo de texto, el foco en la ventana durante las pruebas y la corrección del captura-listener en la versión 19.3.Para ver la lista completa de métodos y sus limitaciones, consulte la documentación oficial de Fragment; las solicitudes de integración relacionadas aparecen en el registro de cambios del proyecto.
Lecturas relacionadas
- Eliminando el Provider Wrapper: Contexto de renderizado desde componentes del servidor — Aprenda cómo React 19.3 permite que un componente del servidor renderice directamente un contexto del cliente, cómo Flight lo serializa y qué limitaciones siguen aplicándose a la lectura y los valores.
- React 19.3 más allá de las noticias: submitter, propiedades Fullscreen y tamaño del bundle — Un recorrido práctico por los cambios menores de React 19.3: la propiedad submitter, las propiedades del evento fullscreen, transiciones en paralelo, onReset, correcciones importantes de errores y el verdadero aumento de tamaño.