Módulo 10: Refs, DOM y portales
Objetivos del módulo
- Entender qué es una ref y en qué se diferencia del estado
- Guardar con
useRefvalores que deben sobrevivir al render sin provocar otro - Conocer las reglas de las refs: por qué no se leen ni se escriben durante el render
- Acceder a nodos del DOM para dar el foco, desplazar la página, medir y dibujar
- Saber en qué momento exacto una ref recibe su valor y cuándo lo pierde
- Usar callback refs, incluida su limpieza, para gestionar listas de nodos
- Pasar una ref a través de tus propios componentes
- Exponer una interfaz reducida con
useImperativeHandle - Sacar contenido de su contenedor con
createPortaly entender qué ocurre con los eventos y el contexto - Valorar cuándo el
<dialog>nativo hace innecesario un portal - Forzar una actualización síncrona con
flushSyncy conocer su coste - Actuar sobre un grupo de nodos sin envoltorio con Fragment Refs
💡 Consejo:
useRef, las refs a nodos,useImperativeHandle,createPortalyflushSyncexisten desde mucho antes de React 18. Lo marcado en este módulo es lo que ha cambiado:refcomo prop y la limpieza de refs llegan en 19.0, y Fragment Refs en 19.3. Hemos ejecutado las diferencias en 18.3.1, 19.0.9, 19.2.9 y 19.3.1, y cada caja cuenta lo que hemos obtenido en cada una.
1. Qué es una ref
Hasta ahora, todo lo que un componente recordaba estaba en el estado. Pero no todos los datos tienen que ver con lo que se pinta. Piensa en el identificador que devuelve setInterval: lo necesitas para parar el temporizador, pero nunca aparece en pantalla. Guardarlo en el estado provocaría un render inútil, y guardarlo en una variable local no sirve porque desaparece en cuanto termina el render, como viste en el módulo 04.
Una ref es la pieza que falta: una caja que React conserva entre renders y que puedes cambiar cuando quieras sin que nadie se entere.
import { useRef } from 'react';
const contador = useRef(0);
// contador es un objeto normal: { current: 0 }
contador.current = contador.current + 1;
useRef recibe el valor inicial y devuelve un objeto con una sola propiedad, current. React te da el mismo objeto en todos los renders. Por eso una ref no hace falta en la lista de dependencias de un efecto, como viste en el módulo 09.
📘 Concepto: Una ref es un objeto
{ current: valor }que React guarda entre renders. Puedes leerlo y cambiarlo directamente, y cambiarlo no provoca un nuevo render.
Compara las dos memorias en un mismo componente:
import { useRef, useState } from 'react';
export default function Comparacion() {
const [enEstado, setEnEstado] = useState(0);
const enRef = useRef(0);
console.log('render');
function sumarEstado() {
setEnEstado(enEstado + 1);
console.log('estado justo después de pedir el cambio:', enEstado);
}
function sumarRef() {
enRef.current = enRef.current + 1;
console.log('ref justo después de cambiarla:', enRef.current);
}
return (
<section className="tarjeta">
<h2>Estado frente a ref</h2>
<p>Valor del estado: {enEstado}</p>
<div className="fila">
<button onClick={sumarEstado}>Sumar al estado</button>
<button onClick={sumarRef}>Sumar a la ref</button>
</div>
</section>
);
}
Pulsa dos veces Sumar a la ref y una vez Sumar al estado. Esta es la consola:
ref justo después de cambiarla: 1
ref justo después de cambiarla: 2
estado justo después de pedir el cambio: 0
render
render
Hay dos diferencias, y las dos son importantes:
- Los clics en la ref no generan ninguna línea
render. El valor cambia y React no hace nada. - La ref cambia de inmediato, en esa misma línea. El estado sigue valiendo 0 en la función que pidió el cambio, porque cada render trabaja con su instantánea (módulo 05).
render sale dos veces por <StrictMode>, que en desarrollo llama dos veces a cada componente.
Figura 1. El estado provoca un render; la ref solo cambia una caja.
useState | useRef | |
|---|---|---|
| Devuelve | [valor, setValor] | { current: valor } |
| Cambiarlo provoca un render | Sí | No |
| El valor nuevo se ve | En el siguiente render | Al momento |
| Se puede usar en el JSX | Sí | No debe usarse: la pantalla se quedaría atrás |
| Sobrevive al render | Sí | Sí |
| Una copia por instancia | Sí | Sí |
| Para qué sirve | Lo que se ve en pantalla | Lo que solo necesita tu código |
Por dentro
No hay magia. Podrías construir una ref con useState guardando un objeto y sin usar nunca el set:
function useRefCasero<T>(inicial: T) {
const [ref] = useState({ current: inicial });
return ref;
}
Como nadie llama al set, nunca hay render, y como el objeto es siempre el mismo, sus cambios se conservan. React lo ofrece como un hook propio porque el uso es muy frecuente, y para que las herramientas de lint puedan vigilar cómo lo usas.
Prepara el proyecto
Para probar los ejemplos crea un proyecto como en el módulo 01:
npm create vite@latest modulo-10 -- --template react-ts
cd modulo-10
npm install
npm run dev
Solo el caso práctico de la sección 10 necesita la API: descarga recursos/empresa-api.zip, descomprímelo y arráncalo con node server.mjs. Quedará en http://localhost:3000/api.
Cada ejemplo completo de este módulo sustituye a src/App.tsx. Borra src/App.css y sustituye src/index.css por estas reglas, que usan todos los ejemplos:
:root {
font-family: 'Segoe UI', system-ui, sans-serif;
color: #263238;
background: #f5f6fa;
}
body {
margin: 0;
padding: 1.25rem;
}
.tarjeta {
background: #fff;
border: 1px solid #c5cae9;
border-radius: 10px;
padding: 1rem 1.25rem;
max-width: 620px;
margin: 0 0 1rem;
}
.tarjeta h2,
.modal h2,
.dialogo h2 {
margin: 0 0 0.5rem;
font-size: 1.15rem;
color: #1a237e;
}
.fila {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
align-items: center;
margin-bottom: 0.5rem;
}
button {
font: inherit;
padding: 0.35rem 0.8rem;
border: 1px solid #3f51b5;
border-radius: 6px;
background: #e8eaf6;
color: #1a237e;
cursor: pointer;
}
input,
textarea {
font: inherit;
padding: 0.35rem 0.5rem;
border: 1px solid #90a4ae;
border-radius: 6px;
box-sizing: border-box;
}
textarea {
width: 100%;
}
input[aria-invalid='true'] {
border-color: #d84315;
}
.grande {
font-size: 2.2rem;
margin: 0.25rem 0 0.75rem;
color: #283593;
font-variant-numeric: tabular-nums;
}
.campo {
display: flex;
flex-direction: column;
gap: 0.2rem;
margin-bottom: 0.75rem;
}
.error {
color: #d84315;
}
.caja-scroll {
height: 200px;
overflow: auto;
border: 1px solid #cfd8dc;
border-radius: 6px;
padding: 0.5rem 1.5rem;
margin: 0;
}
.caja-scroll.baja {
height: 96px;
overflow: auto;
}
.marcado {
background: #ffccbc;
}
.panel-medido {
margin: 0.5rem 0;
padding: 0.75rem;
background: #e8eaf6;
border-radius: 6px;
overflow-wrap: anywhere;
}
/* Modales y portales */
.modal-fondo {
position: fixed;
inset: 0;
z-index: 10;
display: grid;
place-items: center;
background: rgb(38 50 56 / 45%);
}
.modal,
.dialogo {
background: #fff;
border: 1px solid #c5cae9;
border-radius: 10px;
padding: 1rem 1.25rem;
width: min(420px, 90vw);
box-sizing: border-box;
outline: none;
}
.dialogo::backdrop {
background: rgb(38 50 56 / 45%);
}
.caja-recortada {
height: 170px;
overflow: hidden;
transform: translateZ(0);
}
.aviso-flotante {
position: fixed;
right: 1rem;
bottom: 1rem;
padding: 0.5rem 1rem;
background: #e8eaf6;
border: 1px solid #3f51b5;
border-radius: 8px;
}
.con-ayuda {
border-bottom: 1px dashed #3f51b5;
cursor: help;
}
.burbuja {
position: fixed;
z-index: 20;
max-width: 260px;
padding: 0.35rem 0.6rem;
background: #263238;
color: #fff;
border-radius: 6px;
font-size: 0.875rem;
}
.tabla {
width: 100%;
border-collapse: collapse;
}
.tabla th,
.tabla td {
padding: 0.3rem 0.5rem;
border-bottom: 1px solid #cfd8dc;
text-align: left;
}
.tabla tr[data-activa='true'] {
background: #ffccbc;
}
.toasts {
position: fixed;
right: 1rem;
bottom: 1rem;
z-index: 30;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.toast {
padding: 0.5rem 0.9rem;
background: #263238;
color: #fff;
border-radius: 6px;
}
useRef exige un argumento 🔄 19.0
🔄 Cambia en React 19.0. Con
@types/react18 se podía escribiruseRef<number>(), y los tipos distinguían unRefObjectde solo lectura y unMutableRefObject. Con los tipos de 19, el argumento es obligatorio y solo quedaRefObject, concurrentsiempre modificable. Si usas una versión anterior: pasa siempre un argumento, por ejemplouseRef<number | undefined>(undefined), que compila en ambas.
Este cambio es de los tipos, no del comportamiento. En @types/react 19.3.0, useRef<number>() da error de compilación, y MutableRefObject sigue existiendo solo como tipo obsoleto. Tipar una ref es sencillo:
const intervalo = useRef<number | null>(null); // Empieza vacía
const nodo = useRef<HTMLInputElement>(null); // current es HTMLInputElement | null
const total = useRef(0); // Se deduce number
El módulo 13 repasa los tipos de refs con más detalle.
2. Valores mutables: más allá del DOM
Mucha gente cree que las refs solo sirven para tocar el DOM. Su primer uso es otro: recordar algo que tu código necesita y la pantalla no.
Guardar un temporizador
Queremos cronometrar la atención a un cliente. La hora de inicio y la actual se muestran, así que son estado. El identificador del intervalo no se muestra, pero hay que conservarlo para poder pararlo:
import { useEffect, useRef, useState } from 'react';
function formatear(ms: number) {
const segundos = Math.floor(ms / 1000);
const decimas = Math.floor((ms % 1000) / 100);
return `${segundos},${decimas} s`;
}
export default function Cronometro() {
const [inicio, setInicio] = useState<number | null>(null);
const [ahora, setAhora] = useState<number | null>(null);
const intervalo = useRef<number | null>(null);
function parar() {
if (intervalo.current !== null) {
clearInterval(intervalo.current);
intervalo.current = null;
}
}
function arrancar() {
parar();
const momento = Date.now();
setInicio(momento);
setAhora(momento);
intervalo.current = setInterval(() => setAhora(Date.now()), 100);
}
// Si el componente desaparece con el cronómetro en marcha, lo paramos.
useEffect(() => parar, []);
const transcurrido = inicio !== null && ahora !== null ? ahora - inicio : 0;
return (
<section className="tarjeta">
<h2>Tiempo de atención al cliente</h2>
<p className="grande" data-testid="tiempo">{formatear(transcurrido)}</p>
<div className="fila">
<button onClick={arrancar}>Arrancar</button>
<button onClick={parar}>Parar</button>
</div>
</section>
);
}

Figura 2. Tras pulsar Parar, el tiempo se detiene en 2,4 s.
Fíjate en tres detalles:
pararfunciona porque leeintervalo.current, que siempre es el valor más reciente. Si el identificador estuviera en una variable local,pararvería la del render en el que se creó.arrancarempieza llamando aparar. Así, pulsar dos veces Arrancar no deja un intervalo huérfano que nadie podría detener.useEffect(() => parar, [])devuelvepararcomo limpieza. Si el componente desaparece, el intervalo muere con él.
Un detalle de tipos: en una aplicación creada con esta plantilla, setInterval devuelve number. Si tu proyecto incluye los tipos de Node, puede devolver un objeto Timeout. La forma que vale en ambos casos es useRef<ReturnType<typeof setInterval> | null>(null).
Leer el valor más reciente desde código antiguo
Una función creada en un render ve los valores de ese render. Es lo que el módulo 09 llama la clausura vieja. Casi siempre es lo que quieres, pero a veces necesitas lo contrario: que un temporizador, al terminar, lea lo que hay ahora.
import { useEffect, useRef, useState } from 'react';
export default function MensajeDiferido() {
const [texto, setTexto] = useState('');
const [resultado, setResultado] = useState<string[]>([]);
const textoActual = useRef(texto);
// Tras cada cambio, la ref guarda el último valor confirmado.
useEffect(() => {
textoActual.current = texto;
}, [texto]);
function programar() {
setTimeout(() => {
setResultado([
`El estado capturado dice: "${texto}"`,
`La ref dice: "${textoActual.current}"`,
]);
}, 2000);
}
return (
<section className="tarjeta">
<h2>Enviar dentro de dos segundos</h2>
<div className="fila">
<input value={texto} onChange={(e) => setTexto(e.target.value)} />
<button onClick={programar}>Programar</button>
</div>
{resultado.map((linea) => <p key={linea}>{linea}</p>)}
</section>
);
}
Escribe hola, pulsa Programar y sigue escribiendo enseguida hola, equipo de ventas. Dos segundos después aparece esto:
El estado capturado dice: "hola"
La ref dice: "hola, equipo de ventas"
La función del temporizador se creó cuando texto valía hola, y conserva ese valor para siempre. La ref es el mismo objeto en todos los renders, así que al leerla obtiene lo último que se guardó.
Ninguno de los dos resultados es incorrecto en sí. Si es un envío con “deshacer”, suele interesar lo que había al pulsar, y si es un autoguardado, lo último escrito. Tú decides según el caso.
💡 Consejo: La ref se actualiza en un efecto y no durante el render por la regla que verás a continuación. Si tienes React 19.2 o superior y lo necesitas dentro de un efecto,
useEffectEventresuelve lo mismo con menos código (módulo 09).
La regla: nada de refs durante el render
Un componente debe ser puro: mismas props y estado, mismo resultado. Leer o escribir ref.current en el cuerpo del componente rompe esa promesa, porque el resultado pasa a depender de una caja que cambia sin avisar.
// ❌ MAL: escribir durante el render
export default function Contador() {
const renders = useRef(0);
renders.current += 1; // Strict Mode lo ejecuta dos veces
const veces = renders.current; // ¿Qué render estás viendo?
return <p>Renders: {veces}</p>;
}
Con esto pueden pasar varias cosas:
- En Strict Mode el componente se ejecuta dos veces, y el contador sube de dos en dos.
- Si React empieza un render y lo descarta, cosa que ocurre en el renderizado concurrente (módulo 15), la escritura ya está hecha.
- La pantalla muestra un valor antiguo: al cambiar la ref no hay render, así que el JSX se queda como estaba.
- React Compiler (módulo 14) da por hecho que tus componentes son puros, y puede dejar sin optimizar el que no lo sea.
| Dónde | ¿Puedes leer o escribir ref.current? |
|---|---|
| Cuerpo del componente | No |
| JSX que devuelves | No |
| Manejadores de eventos | Sí |
| Efectos y su limpieza | Sí |
| Temporizadores, promesas y callbacks | Sí |
oxlint lo detecta con la plantilla de Vite, sin configurar nada más. Esta es su salida con el código de arriba:
! react(refs): Cannot access refs during render
help: React refs are values that are not needed for rendering.
La excepción: inicialización perezosa
Hay un solo caso aceptado: crear el contenido una vez, comprobando antes que está vacío. El resultado es siempre el mismo, así que el componente sigue siendo predecible, y el lint lo acepta.
const cola = useRef<ColaDeEnvios | null>(null);
if (cola.current === null) {
cola.current = new ColaDeEnvios(); // Solo en el primer render
}
¿Por qué no escribir useRef(new ColaDeEnvios())? Porque JavaScript evalúa los argumentos antes de llamar a la función. Crearías un objeto en cada render, y React descartaría todos menos el primero. Con un Map vacío el coste es despreciable. Con algo caro, o algo que abra una conexión, usa la forma de arriba.
¿Ref o estado?
| Dato | ¿Dónde? | Por qué |
|---|---|---|
| Texto de un buscador | Estado | Se ve en pantalla |
Identificador de setInterval | Ref | Solo lo usa clearInterval |
| Fila seleccionada | Estado | Cambia cómo se pinta la fila |
AbortController en curso | Ref | Solo se usa para cancelar |
| Bloqueo contra el doble clic | Ref, y estado si además deshabilitas el botón | La ref cambia al momento (ejercicio 3) |
| Última posición de scroll | Ref, si no se pinta | Cambiaría decenas de veces por segundo |
| Instancia de un mapa o gráfico externo | Ref | No es un dato de React |
Una regla práctica: si al borrar el dato del JSX nada se rompe, probablemente era una ref.
3. Referencias a nodos del DOM
React actualiza el DOM por ti, y eso cubre casi todo. Pero hay operaciones que el HTML no sabe expresar como atributo: dar el foco, desplazar hasta un elemento, medirlo o reproducir un vídeo. Son órdenes, no descripciones, y necesitan el nodo real.
Para conseguirlo, crea una ref vacía y pásala en el atributo ref de una etiqueta:
const campo = useRef<HTMLInputElement>(null);
// ...
<input ref={campo} />
// ...
campo.current?.focus();
ref es un atributo especial. React no lo pone en el HTML: cuando crea el nodo, guarda el elemento en campo.current.
Cuándo tiene valor
React coloca los nodos en el documento y asigna las refs en la fase de commit que estudiaste en el módulo 05. Durante el primer render el nodo todavía no está disponible, y por eso current vale null. Esta es una traza real con una ref, un useLayoutEffect y un useEffect, montando y desmontando:
render:null
layout:nodo
effect:nodo
limpiezaLayout:nodo
limpiezaEffect:null
Figura 3. Cuándo está disponible el nodo, al montar y al desmontar. El orden es el mismo en 18.3.1, 19.0.9, 19.2.9 y 19.3.1.
De aquí sale una consecuencia práctica. En la limpieza de un useEffect, ref.current ya es null si el componente se ha desmontado. El lint te avisa:
! react-hooks(exhaustive-deps): The ref's value `.current` is accessed directly in the effect cleanup function.
La solución es copiar el nodo a una variable dentro del efecto, como verás en todos los ejemplos con addEventListener u observadores:
useEffect(() => {
const nodo = caja.current; // Copia local, no cambiará
nodo?.addEventListener('scroll', alDesplazar);
return () => nodo?.removeEventListener('scroll', alDesplazar);
}, []);
⚠️ Importante: Si el nodo está tras una condición, como
{abierto && <input ref={campo} />},currentseránullmientras no se muestre. Por eso usamos?.. TypeScript te obliga, porque el tipo esHTMLInputElement | null.
Foco
Dar el foco es el uso más común. El buscador siguiente acepta la tecla /, como GitHub, además de un botón:
import { useEffect, useRef, useState } from 'react';
const empleados = ['Ana García', 'Luis Martínez', 'Carmen Ruiz', 'Pedro Sánchez'];
export default function Buscador() {
const [texto, setTexto] = useState('');
const campo = useRef<HTMLInputElement>(null);
useEffect(() => {
function alPulsar(e: KeyboardEvent) {
const escribiendo = document.activeElement === campo.current;
if (e.key === '/' && !escribiendo) {
e.preventDefault(); // Sin esto, la "/" acabaría escrita en el campo.
campo.current?.focus();
campo.current?.select();
}
}
document.addEventListener('keydown', alPulsar);
return () => document.removeEventListener('keydown', alPulsar);
}, []);
const visibles = empleados.filter((nombre) =>
nombre.toLowerCase().includes(texto.toLowerCase()),
);
return (
<section className="tarjeta">
<h2>Empleados</h2>
<div className="fila">
<input
ref={campo}
value={texto}
onChange={(e) => setTexto(e.target.value)}
placeholder="Pulsa / para buscar"
/>
<button onClick={() => campo.current?.focus()}>Ir al buscador</button>
</div>
<ul>
{visibles.map((nombre) => <li key={nombre}>{nombre}</li>)}
</ul>
</section>
);
}
Prueba a pulsar /: el cursor entra en el campo y la barra no se escribe gracias a preventDefault. Si ya estás escribiendo, la comprobación escribiendo deja pasar la tecla, y puedes buscar un texto con / (en nuestra prueba, con el campo en an, otra / da an/).
Si solo quieres foco al montar, no necesitas nada de esto: <input autoFocus /> lo hace. React llama a focus() al crear el nodo, sin efecto ni ref.
Desplazamiento
Hay dos herramientas principales, según lo que tengas:
| Método | Se llama sobre | Sirve para |
|---|---|---|
nodo.scrollIntoView(opciones) | El elemento que quieres ver | Traerlo a la vista, moviendo todo lo que haga falta |
contenedor.scrollTo({ top }) | La caja con scroll | Ir a una posición exacta |
contenedor.scrollTop = n | La caja con scroll | Lo mismo, sin animación |
import { useRef } from 'react';
const lineas = Array.from({ length: 40 }, (_, i) => `Movimiento de almacén ${i + 1}`);
export default function Historial() {
const caja = useRef<HTMLDivElement>(null);
const ultima = useRef<HTMLParagraphElement>(null);
return (
<section className="tarjeta">
<h2>Historial</h2>
<div className="fila">
<button onClick={() => caja.current?.scrollTo({ top: 0, behavior: 'smooth' })}>
Al principio
</button>
<button onClick={() => ultima.current?.scrollIntoView({ behavior: 'smooth', block: 'end' })}>
Al final
</button>
</div>
<div ref={caja} className="caja-scroll">
{lineas.map((linea) => <p key={linea}>{linea}</p>)}
<p ref={ultima}><strong>Fin del historial</strong></p>
</div>
</section>
);
}
block: 'end' alinea el final del elemento con el final de la caja; block: 'center' lo centra. behavior: 'smooth' lo anima; si el movimiento es grande, conviene respetar la preferencia prefers-reduced-motion. Si la página también tiene scroll, scrollIntoView mueve las dos cosas, la caja y la página, cuando el elemento está fuera de la ventana.
Medir
El tamaño de un elemento no se conoce hasta que está en el DOM, porque depende de los estilos, el texto y la ventana. Hay varias formas de preguntarlo:
| Propiedad | Devuelve | Incluye |
|---|---|---|
getBoundingClientRect() | Posición y tamaño respecto a la ventana, con decimales | Borde y relleno, y refleja transform |
offsetWidth, offsetHeight | Tamaño entero | Borde y relleno |
clientWidth, clientHeight | Tamaño entero | Relleno, sin borde ni barra de scroll |
scrollHeight | Alto total del contenido | También lo que no se ve |
Para una medida puntual que afecta a lo que se pinta, usa useLayoutEffect, que corre antes del pintado (módulo 09). Lo verás en la sección 7. Si el tamaño puede cambiar luego, por ejemplo al escribir o al redimensionar la ventana, una sola medida no basta. Para eso existe ResizeObserver:
import { useEffect, useRef, useState } from 'react';
export default function PanelMedido() {
const panel = useRef<HTMLDivElement>(null);
const [tamano, setTamano] = useState({ ancho: 0, alto: 0 });
const [notas, setNotas] = useState('Notas de la reunión de ventas.');
useEffect(() => {
const nodo = panel.current;
if (!nodo) return;
const observador = new ResizeObserver(([entrada]) => {
const caja = entrada.borderBoxSize[0];
setTamano({
ancho: Math.round(caja.inlineSize),
alto: Math.round(caja.blockSize),
});
});
observador.observe(nodo);
return () => observador.disconnect();
}, []);
return (
<section className="tarjeta">
<h2>Panel que se mide solo</h2>
<textarea rows={2} value={notas} onChange={(e) => setNotas(e.target.value)} />
<div ref={panel} className="panel-medido">{notas}</div>
<p data-testid="tamano">
El panel mide {tamano.ancho} × {tamano.alto} píxeles
</p>
</section>
);
}

Figura 4. Al escribir, el panel pasa de 45 a 108 píxeles de alto, y el observador lo notifica sin que tengas que medir a mano.
El observador avisa también justo después de observe, así que no hace falta una medida inicial. No se te olvide disconnect en la limpieza: sin ella, el observador sigue activo aunque el componente haya desaparecido.
El set dentro del observador no es un problema: ocurre en un callback cuando el sistema externo avisa, no en el cuerpo del efecto. Es justo lo que hace un efecto bien usado.
Integrar código que no es React
<canvas> no tiene hijos que React pueda gestionar. Se dibuja mediante órdenes, igual que trabajan muchas librerías de mapas, gráficos o editores. El patrón es siempre el mismo: una ref para el nodo y un efecto que sincroniza.
import { useEffect, useRef, useState } from 'react';
interface Dato {
nombre: string;
plantilla: number;
}
const datos: Dato[] = [
{ nombre: 'Ventas', plantilla: 6 },
{ nombre: 'Informática', plantilla: 6 },
{ nombre: 'Logística', plantilla: 4 },
{ nombre: 'Contabilidad', plantilla: 3 },
{ nombre: 'Marketing', plantilla: 3 },
{ nombre: 'Dirección', plantilla: 1 },
];
function dibujar(lienzo: HTMLCanvasElement, lista: Dato[], color: string) {
const ctx = lienzo.getContext('2d');
if (!ctx) return;
ctx.clearRect(0, 0, lienzo.width, lienzo.height);
ctx.font = '14px Segoe UI, sans-serif';
ctx.textBaseline = 'middle';
lista.forEach((dato, i) => {
const y = 12 + i * 34;
ctx.fillStyle = '#37474f';
ctx.fillText(dato.nombre, 8, y + 12);
ctx.fillStyle = color;
ctx.fillRect(120, y, dato.plantilla * 62, 24);
ctx.fillStyle = '#37474f';
ctx.fillText(String(dato.plantilla), 128 + dato.plantilla * 62, y + 12);
});
}
export default function GraficoPlantilla() {
const [color, setColor] = useState('#3f51b5');
const lienzo = useRef<HTMLCanvasElement>(null);
useEffect(() => {
if (lienzo.current) dibujar(lienzo.current, datos, color);
}, [color]);
return (
<section className="tarjeta">
<h2>Plantilla por departamento</h2>
<canvas ref={lienzo} width={540} height={220} />
<div className="fila">
<button onClick={() => setColor('#3f51b5')}>Azul</button>
<button onClick={() => setColor('#ff7043')}>Naranja</button>
</div>
</section>
);
}

Figura 5. React gestiona el <canvas> y el estado del color; el contenido del lienzo es cosa tuya.
Es la idea del módulo 09: el efecto sincroniza un sistema externo con el estado. Si color cambia, se vuelve a dibujar. Con librerías que crean un objeto, guarda la instancia en otra ref y destrúyela en la limpieza del efecto.
Lo que no debes hacer
React cree que el DOM es exactamente lo que él dejó. Si cambias su estructura a sus espaldas, esa creencia deja de ser cierta:
import { useRef, useState } from 'react';
export default function RomperDom() {
const [visible, setVisible] = useState(true);
const parrafo = useRef<HTMLParagraphElement>(null);
return (
<section className="tarjeta">
<div className="fila">
<button onClick={() => setVisible(!visible)}>Alternar con estado</button>
<button onClick={() => parrafo.current?.remove()}>Quitar con remove()</button>
</div>
{visible && <p ref={parrafo}>Este párrafo pertenece a React.</p>}
</section>
);
}
Pulsa Quitar con remove() y luego Alternar con estado. React intenta quitar un párrafo que ya no está:
Failed to execute 'removeChild' on 'Node': The node to be removed is not a child of this node.
El error no está capturado, así que React desmonta la aplicación y la página se queda en blanco.
| Operación sobre un nodo de React | ¿Segura? |
|---|---|
focus(), scrollIntoView(), medir, animate() | Sí, no cambian la estructura |
play(), pause(), showModal() | Sí |
style.left, textContent en un nodo sin hijos de React | Sí, con cuidado: un render puede sobrescribir lo que React controle |
remove(), appendChild(), innerHTML en nodos con hijos de React | No |
| Añadir hijos a un nodo vacío que React nunca rellena | Sí: así se integran muchas librerías |
💡 Consejo: Antes de escribir una ref, pregúntate si se puede resolver con una prop y estado. Mostrar u ocultar, cambiar una clase o el texto son cosas declarativas. Las refs quedan para lo que no se puede expresar así.
4. Callback refs
ref no solo acepta un objeto. También acepta una función, que React llama cuando el nodo aparece. Es útil cuando necesitas reaccionar al momento en que existe, o cuando no sabes cuántos nodos habrá.
<div ref={(nodo) => {
if (nodo) console.log('Ya existe:', nodo.offsetHeight);
}} />
Sin limpieza, React la llama con el nodo al montar y con null al desmontar. Esto funciona igual en todas las versiones.
La limpieza de refs 🆕 19.0
🆕 Desde React 19.0. No existe en React 18.3 ni anteriores: la función devuelta se ignora y sale un aviso en consola. Si usas una versión anterior: no devuelvas nada y limpia cuando la función reciba
null, guardando lo necesario en otra ref.
Ahora una callback ref puede devolver una función de limpieza, igual que un efecto. Si lo hace, React la llama al soltar el nodo y no vuelve a llamarte con null.
Esto es lo que obtuvimos con una función que anota lo que recibe y devuelve una limpieza, al montar y desmontar:
| Versión | Llamadas | Aviso |
|---|---|---|
| 18.3.1 | nodo, null | Unexpected return value from a callback ref |
| 19.0.9, 19.2.9, 19.3.1 | nodo, limpieza | Ninguno |
Función en línea y función estable 🆕 19.0
🆕 Desde React 19.0. El ejemplo devuelve una limpieza, que no existe en React 18.3. Si usas una versión anterior: quita los
return; verás una llamada connullen lugar de cada línealimpiar.
React compara la función con la del render anterior. Si es otra, suelta la anterior y enlaza la nueva. Una función escrita en el JSX es nueva en cada render:
import { useCallback, useState } from 'react';
export default function CicloDeRefs() {
const [renders, setRenders] = useState(0);
const [visible, setVisible] = useState(true);
const enlazarEstable = useCallback((nodo: HTMLParagraphElement) => {
console.log('estable: enlazar', nodo.tagName);
return () => console.log('estable: limpiar');
}, []);
return (
<section className="tarjeta">
<div className="fila">
<button onClick={() => setRenders(renders + 1)}>Renderizar ({renders})</button>
<button onClick={() => setVisible(!visible)}>Montar o desmontar</button>
</div>
{visible && (
<>
<p ref={enlazarEstable}>Función estable</p>
<p
ref={(nodo) => {
console.log('en línea: enlazar', nodo?.tagName);
return () => console.log('en línea: limpiar');
}}
>
Función en línea
</p>
</>
)}
</section>
);
}
useCallback conserva la misma función entre renders; se estudia en el módulo 14. Veamos la consola en cada momento.
Al montar:
estable: enlazar P
en línea: enlazar P
estable: limpiar
en línea: limpiar
estable: enlazar P
en línea: enlazar P
Al pulsar Renderizar:
en línea: limpiar
en línea: enlazar P
Al desmontar:
estable: limpiar
en línea: limpiar
Con la función en línea, cada render desenlaza y enlaza. Para guardar un nodo en un Map no tiene importancia. Pero si la función crea un observador o se suscribe a algo, repetirás ese trabajo en cada render, y entonces conviene que sea estable. La forma más simple de conseguirlo es definirla fuera del componente, si no necesita nada de él (lo verás en el ejercicio 5).
Strict Mode ensaya también las refs 🔄 19.0
🔄 Cambia en React 19.0. En desarrollo, Strict Mode enlaza, suelta y vuelve a enlazar las refs al montar. En React 18.3 no lo hace: la llamada es una sola. Si usas una versión anterior: no tienes que cambiar nada, pero tampoco verás los errores que este ensayo destapa, así que escribe tus limpiezas igual de bien.
Por eso, al montar, la consola anterior tiene seis líneas. Es el mismo ensayo que hace con los efectos (módulo 09), y persigue lo mismo: comprobar que tu limpieza deshace lo que hizo la función. Con una función que anota lo recibido, sin limpieza, en Strict Mode y al montar:
| Versión | Llamadas |
|---|---|
| 18.3.1 | nodo |
| 19.0.9, 19.2.9, 19.3.1 | nodo, null, nodo |
El retorno implícito 🔄 19.0
🔄 Cambia en React 19.0. Con
@types/react18, una callback ref podía devolver cualquier valor. Con los de 19, solovoido una función de limpieza. Si usas una versión anterior: tu código compila, pero añade llaves igualmente, porque así seguirá compilando al actualizar.
Antes de React 19, esto era habitual:
<div ref={(nodo) => (instancia = nodo)} />
Con la limpieza, un valor devuelto sin querer podría confundirse con una función, así que los tipos de 19 lo rechazan con el error TS2322: una callback ref solo puede devolver void o una función. La solución es añadir llaves para no devolver nada:
<div ref={(nodo) => { instancia = nodo; }} />
Una lista de nodos 🆕 19.0
🆕 Desde React 19.0. El ejemplo limpia el
Mapdevolviendo una función, algo que React 18.3 ignora. Si usas una versión anterior: borra la entrada cuando la función recibanull, como muestra el final de este apartado.
No puedes llamar a useRef dentro de un map: las reglas de los hooks lo prohíben, y además el número de elementos cambia. La solución es una sola ref con un Map dentro, que se rellena con callback refs:
import { useRef, useState } from 'react';
interface Empleado {
id: number;
nombre: string;
}
const empleados: Empleado[] = Array.from({ length: 24 }, (_, i) => ({
id: 100 + i,
nombre: `Empleado ${100 + i}`,
}));
export default function DirectorioEmpleados() {
const nodos = useRef(new Map<number, HTMLLIElement>());
const [marcado, setMarcado] = useState<number | null>(null);
const [lista, setLista] = useState(empleados);
function irA(id: number) {
setMarcado(id);
nodos.current.get(id)?.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
function quitarPrimero() {
setLista(lista.slice(1));
}
return (
<section className="tarjeta">
<h2>Directorio</h2>
<div className="fila">
<button onClick={() => irA(105)}>Ir al 105</button>
<button onClick={() => irA(120)}>Ir al 120</button>
<button onClick={quitarPrimero}>Quitar el primero</button>
<button onClick={() => console.log('nodos guardados:', nodos.current.size)}>
Contar nodos
</button>
</div>
<ul className="caja-scroll">
{lista.map((e) => (
<li
key={e.id}
className={e.id === marcado ? 'marcado' : undefined}
ref={(nodo) => {
if (nodo) nodos.current.set(e.id, nodo);
return () => {
nodos.current.delete(e.id);
};
}}
>
{e.nombre}
</li>
))}
</ul>
</section>
);
}

Figura 6. Con el Map se llega a cualquier fila por su identificador.
Al pulsar Contar nodos, Quitar el primero y otra vez Contar nodos, la consola muestra:
nodos guardados: 24
nodos guardados: 23
La limpieza borra del Map lo que deja de existir. Sin ella, seguiría allí un nodo desmontado, y además no podría liberarse la memoria que ocupa.
En React 18 se escribe así, sin devolver nada:
ref={(nodo) => {
if (nodo) nodos.current.set(e.id, nodo);
else nodos.current.delete(e.id);
}}
5. Refs en tus propios componentes 🆕 19.0
🆕 Desde React 19.0. No existe en React 18.3 ni anteriores:
refno llega como prop a un componente función. Si usas una versión anterior: envuelve el componente enforwardRef, que recibe la ref como segundo parámetro.
Un <input> dentro de tu componente es un detalle privado. Pero un formulario puede necesitar darle el foco. El módulo 03 ya explicó cómo pasar una ref como prop; aquí lo aplicamos a un caso real: llevar al usuario al primer campo con error.
import { useRef, useState, type ComponentProps, type FormEvent } from 'react';
type CampoProps = ComponentProps<'input'> & {
etiqueta: string;
error?: string;
};
function Campo({ etiqueta, error, ref, ...resto }: CampoProps) {
return (
<label className="campo">
{etiqueta}
<input ref={ref} aria-invalid={error ? true : undefined} {...resto} />
{error && <small className="error">{error}</small>}
</label>
);
}
export default function AltaEmpleado() {
const [errores, setErrores] = useState<{ nombre?: string; email?: string }>({});
const nombre = useRef<HTMLInputElement>(null);
const email = useRef<HTMLInputElement>(null);
function enviar(e: FormEvent<HTMLFormElement>) {
e.preventDefault();
const nuevos: typeof errores = {};
if (!nombre.current?.value.trim()) nuevos.nombre = 'Escribe el nombre';
if (!email.current?.value.includes('@')) nuevos.email = 'El correo no es válido';
setErrores(nuevos);
// Lleva al usuario al primer campo con error.
if (nuevos.nombre) nombre.current?.focus();
else if (nuevos.email) email.current?.focus();
}
return (
<form className="tarjeta" onSubmit={enviar} noValidate>
<h2>Alta de empleado</h2>
<Campo ref={nombre} etiqueta="Nombre" error={errores.nombre} />
<Campo ref={email} etiqueta="Correo" type="email" error={errores.email} />
<button>Guardar</button>
</form>
);
}

Figura 7. Al guardar, el foco salta al primer campo incorrecto, algo importante para quien usa teclado o lector de pantalla.
ComponentProps<'input'> incluye ref, así que el tipo ya está resuelto. Aquí la sacamos por claridad, pero si no la nombras llega igualmente dentro de resto.
Si pasas una ref a un componente que no la usa, no pasa nada. En 19 es una prop que se ignora, sin aviso; la ref se queda como estaba. En 18.3.1 obtuvimos Function components cannot be given refs.
La versión con forwardRef sigue funcionando en 19.3, y la necesitas si tu librería debe admitir React 18:
import { forwardRef } from 'react';
const Campo = forwardRef<HTMLInputElement, CampoProps>(
function Campo({ etiqueta, error, ...resto }, ref) {
return (
<label className="campo">
{etiqueta}
<input ref={ref} {...resto} />
{error && <small className="error">{error}</small>}
</label>
);
},
);
6. useImperativeHandle 🔄 19.0
🔄 Cambia en React 19.0. El hook existe desde hace años, pero los ejemplos de esta sección reciben
refcomo prop. Si usas una versión anterior: envuelve el componente enforwardRefy pasa auseImperativeHandlela ref de su segundo parámetro; el resto del código es igual.
Pasar la ref al <input> entrega el nodo entero al padre, que podría cambiarle el estilo, leerle el valor o quitarlo del DOM. A veces prefieres ofrecer solo unas operaciones, como hace un mando a distancia: tienes botones, no acceso al circuito.
useImperativeHandle(ref, () => ({
// Lo que verá el padre en ref.current
}), [dependencias]);
Tiene tres argumentos: la ref recibida, una función que crea el objeto a exponer y una lista de dependencias que funciona como la de useEffect.
Vamos a crear un campo que se valida a sí mismo. El padre no conoce la expresión regular ni el error; solo pide validar():
import { useImperativeHandle, useRef, useState, type Ref } from 'react';
export interface CampoValidadoHandle {
validar: () => boolean;
limpiar: () => void;
}
interface CampoValidadoProps {
etiqueta: string;
patron: RegExp;
ref?: Ref<CampoValidadoHandle>;
}
function CampoValidado({ etiqueta, patron, ref }: CampoValidadoProps) {
const [valor, setValor] = useState('');
const [error, setError] = useState(false);
const entrada = useRef<HTMLInputElement>(null);
useImperativeHandle(ref, () => ({
validar() {
const correcto = patron.test(valor);
setError(!correcto);
if (!correcto) {
entrada.current?.focus();
entrada.current?.animate(
[{ translate: '-6px' }, { translate: '6px' }, { translate: '0' }],
{ duration: 200, iterations: 2 },
);
}
return correcto;
},
limpiar() {
setValor('');
setError(false);
},
}), [valor, patron]);
return (
<label className="campo">
{etiqueta}
<input
ref={entrada}
value={valor}
aria-invalid={error ? true : undefined}
onChange={(e) => setValor(e.target.value)}
/>
{error && <small className="error">Formato incorrecto</small>}
</label>
);
}
export default function Pedido() {
const [mensaje, setMensaje] = useState('');
const nif = useRef<CampoValidadoHandle>(null);
const codigoPostal = useRef<CampoValidadoHandle>(null);
function comprobar() {
// Validamos de abajo arriba: el foco acaba en el primer campo con error.
const cpBien = codigoPostal.current?.validar() ?? false;
const nifBien = nif.current?.validar() ?? false;
setMensaje(nifBien && cpBien ? 'Datos correctos' : 'Revisa los campos marcados');
}
function limpiar() {
nif.current?.limpiar();
codigoPostal.current?.limpiar();
setMensaje('');
}
return (
<section className="tarjeta">
<h2>Datos de facturación</h2>
<CampoValidado ref={nif} etiqueta="NIF" patron={/^\d{8}[A-Z]$/} />
<CampoValidado ref={codigoPostal} etiqueta="Código postal" patron={/^\d{5}$/} />
<div className="fila">
<button onClick={comprobar}>Comprobar</button>
<button onClick={limpiar}>Limpiar</button>
</div>
<p data-testid="mensaje">{mensaje}</p>
</section>
);
}

Figura 8. El padre solo llama a validar(); cada campo marca su error, se sacude y toma el foco.
Con un NIF correcto y el código postal 28, Comprobar muestra Revisa los campos marcados, y el foco queda en el código postal. Con 28001, muestra Datos correctos. Limpiar vacía los dos campos.
Algunos detalles que importan:
- Tipo propio.
CampoValidadoHandledescribe lo que se expone. El padre lo usa enuseRef<CampoValidadoHandle>(null), y TypeScript solo le deja llamar a esas funciones. - Dependencias.
validarleevalor, así que está en la lista. Sin ella,validarusaría el valor del primer render. El lint lo avisa. - Orden. Llamamos primero al código postal, de abajo arriba, para que el último
focus()sea el del primer campo incorrecto. animatees la Web Animations API del navegador. No cambia la estructura, así que es segura.
Cuándo usarlo
| Situación | Mejor opción |
|---|---|
| Foco, scroll o medir el nodo interno | Pasar la ref al nodo |
| Abrir o cerrar un panel | Una prop abierto |
| Cambiar lo que se ve | Props |
| Reiniciar el estado de un hijo | key distinta (módulo 05) |
| Una acción puntual que no es un estado: reproducir, sacudir, validar ahora | useImperativeHandle |
Si te ves escribiendo ref.current.abrir() y ref.current.cerrar(), seguramente querías una prop. El estado debe bajar por props, y una acción imperativa debe ser la excepción: se usa poco, y sobre todo en componentes de librería.
7. Portales
Un componente pinta sus hijos dentro de su nodo. Normalmente es lo correcto, pero hay contenido que debe salir de ahí: un modal, un menú o una burbuja de ayuda. Si su antepasado tiene overflow: hidden, quedarán recortados.
Con z-index no basta, porque ese valor solo compite dentro de su contexto de apilamiento, y hay propiedades como transform, filter u opacity que crean uno nuevo. Además, un transform en un antepasado cambia el significado de position: fixed: pasa a ser relativo a ese antepasado y no a la ventana. Un modal con fixed queda entonces atrapado en la tarjeta.
import { createPortal } from 'react-dom';
createPortal(hijos, contenedor, clave?)
createPortal recibe lo que quieres pintar y un nodo del DOM donde ponerlo, normalmente document.body. La clave es opcional y sirve cuando devuelves varios portales en una lista. Devuelve algo que puedes colocar en el JSX, y viene de react-dom, no de react, porque es específico del navegador.
📘 Concepto: Un portal pinta sus hijos en otro lugar del DOM, pero siguen siendo parte del árbol de React. Cambia dónde está el HTML, no quién es su padre.
Figura 9. El modal es hijo de la tarjeta para React y de body para el navegador.
Un modal completo
El siguiente ejemplo muestra el problema y la solución con el mismo componente. La tarjeta tiene overflow: hidden y transform, y una prop decide si se usa el portal:
import { useEffect, useId, useRef, useState, type ReactNode } from 'react';
import { createPortal } from 'react-dom';
interface ModalProps {
titulo: string;
onCerrar: () => void;
conPortal: boolean;
children: ReactNode;
}
function Modal({ titulo, onCerrar, conPortal, children }: ModalProps) {
const dialogo = useRef<HTMLDivElement>(null);
const idTitulo = useId();
// Foco: al abrir entra en el modal; al cerrar vuelve a donde estaba.
useEffect(() => {
const anterior = document.activeElement;
dialogo.current?.focus();
return () => {
if (anterior instanceof HTMLElement) anterior.focus();
};
}, []);
useEffect(() => {
function alPulsar(e: KeyboardEvent) {
if (e.key === 'Escape') onCerrar();
}
document.addEventListener('keydown', alPulsar);
return () => document.removeEventListener('keydown', alPulsar);
}, [onCerrar]);
const contenido = (
<div className="modal-fondo" onClick={onCerrar}>
<div
ref={dialogo}
className="modal"
role="dialog"
aria-modal="true"
aria-labelledby={idTitulo}
tabIndex={-1}
onClick={(e) => e.stopPropagation()}
>
<h2 id={idTitulo}>{titulo}</h2>
{children}
<button onClick={onCerrar}>Cerrar</button>
</div>
</div>
);
return conPortal ? createPortal(contenido, document.body) : contenido;
}
export default function FichaEnCaja() {
const [abierto, setAbierto] = useState<'sin' | 'con' | null>(null);
return (
<section className="tarjeta caja-recortada">
<h2>Tarjeta con overflow y transform</h2>
<p>Ana García, directora general.</p>
<div className="fila">
<button onClick={() => setAbierto('sin')}>Modal sin portal</button>
<button onClick={() => setAbierto('con')}>Modal con portal</button>
</div>
{abierto && (
<Modal
titulo="Ficha del empleado"
conPortal={abierto === 'con'}
onCerrar={() => setAbierto(null)}
>
<p>Departamento: Dirección. Localidad: Madrid.</p>
</Modal>
)}
</section>
);
}

Figura 10. Sin portal: el fondo oscuro cubre solo la tarjeta y el modal queda cortado.

Figura 11. Con portal: el mismo componente, ahora en body, cubre toda la ventana.
Un buen modal es más que una caja centrada. Este cumple lo siguiente:
role="dialog"yaria-modalindican a los lectores de pantalla qué es, yaria-labelledbyle da nombre.useIdgenera el identificador (módulo 08).- Foco. Al abrir, el foco entra en el diálogo;
tabIndex={-1}permite darle el foco a undiv. Al cerrar, vuelve a donde estaba. En la prueba, tras pulsarEscape, el foco regresa al botón que lo abrió. Escapecierra, con un escuchador endocumentque se quita en la limpieza.- Dos efectos. El foco se guarda en un efecto con
[], yEscapeen otro con[onCerrar]. El padre pasa una función nueva en cada render. Si estuvieran juntos, cada render del padre devolvería el foco al botón. stopPropagationen el diálogo evita que un clic en el interior llegue al fondo y lo cierre.
Lo que le falta es atrapar el foco: con Tab todavía se puede llegar a lo que hay detrás. Lo tienes en el ejercicio 9, aunque enseguida verás que el navegador ya lo resuelve con <dialog>.
Eventos y contexto siguen el árbol de React
Este es el punto que más sorprende. Como el portal sigue en el árbol de React, sus eventos suben por los padres de React, aunque en el DOM no tengan ninguna relación. También recibe el contexto, que se estudia en el módulo 11.
import { createContext, useContext, useEffect, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
const Departamento = createContext('sin departamento');
function Aviso({ onPulsar }: { onPulsar: () => void }) {
const departamento = useContext(Departamento);
return createPortal(
<div className="aviso-flotante">
<p>Soy un portal. Contexto: {departamento}</p>
<button onClick={onPulsar}>Pulsar en el portal</button>
</div>,
document.body,
);
}
export default function Burbujeo() {
const [registro, setRegistro] = useState<string[]>([]);
const seccion = useRef<HTMLElement>(null);
function anotar(texto: string) {
setRegistro((anterior) => [...anterior, texto]);
}
useEffect(() => {
const nodo = seccion.current;
function alClicNativo() {
anotar('nativo: el clic llega a la sección');
}
nodo?.addEventListener('click', alClicNativo);
return () => nodo?.removeEventListener('click', alClicNativo);
}, []);
return (
<Departamento.Provider value="VENTAS">
<section
ref={seccion}
className="tarjeta"
onClick={() => anotar('React: el clic llega a la sección')}
>
<h2>Sección padre</h2>
<button onClick={() => anotar('botón normal')}>Pulsar dentro</button>
<Aviso onPulsar={() => anotar('botón del portal')} />
<ol data-testid="registro">
{registro.map((linea, i) => <li key={i}>{linea}</li>)}
</ol>
</section>
</Departamento.Provider>
);
}

Figura 12. El aviso está en body, pero lee el contexto de la tarjeta y sus clics llegan a ella.
Departamento.Provider funciona en todas las versiones; en el módulo 11 verás la forma abreviada de React 19. Pulsa Pulsar dentro y después Pulsar en el portal. Este es el registro:
1. nativo: el clic llega a la sección
2. botón normal
3. React: el clic llega a la sección
4. botón del portal
5. React: el clic llega a la sección
El botón normal activa los dos escuchadores. El nativo va primero porque el evento alcanza antes la sección que la raíz, que es donde escucha React (módulo 05). El clic en el portal solo activa el de React: en el DOM, el aviso no está dentro de la sección, y el escuchador nativo no se entera.
| Portal: se comporta según | |
|---|---|
| Props, estado, contexto | El árbol de React |
Eventos de React (onClick) | El árbol de React |
Eventos nativos (addEventListener) | El DOM real |
CSS, herencia, overflow, z-index | El DOM real |
element.contains(nodo) | El DOM real |
Esto tiene dos consecuencias que debes recordar:
- Un
onClicken un padre recibirá los clics del modal. Si una fila de tabla abre un modal y tieneonClick, un clic dentro del modal volverá a activarla. Se evita constopPropagationen el fondo del modal. - Un “clic fuera” hecho con
contains(ejercicio 2) considera que un portal está fuera, aunque para React sea un hijo.
Una burbuja de ayuda
Las burbujas no se pueden colocar con CSS normal si van a sobresalir de una caja con scroll. Hay que medir y situarlas respecto a la ventana. Aquí se reúnen todas las piezas: refs, useLayoutEffect y portal.
import { useLayoutEffect, useRef, useState, type ReactNode } from 'react';
import { createPortal } from 'react-dom';
function Ayuda({ texto, children }: { texto: string; children: ReactNode }) {
const [visible, setVisible] = useState(false);
const disparador = useRef<HTMLSpanElement>(null);
const burbuja = useRef<HTMLDivElement>(null);
// Se coloca antes de pintar, así que nunca se ve en la posición incorrecta.
useLayoutEffect(() => {
if (!visible || !disparador.current || !burbuja.current) return;
const origen = disparador.current.getBoundingClientRect();
const propia = burbuja.current.getBoundingClientRect();
const izquierda = origen.left + origen.width / 2 - propia.width / 2;
burbuja.current.style.left = `${Math.max(8, izquierda)}px`;
burbuja.current.style.top = `${origen.top - propia.height - 6}px`;
}, [visible]);
return (
<>
<span
ref={disparador}
className="con-ayuda"
tabIndex={0}
onMouseEnter={() => setVisible(true)}
onMouseLeave={() => setVisible(false)}
onFocus={() => setVisible(true)}
onBlur={() => setVisible(false)}
>
{children}
</span>
{visible && createPortal(
<div ref={burbuja} className="burbuja" role="tooltip">{texto}</div>,
document.body,
)}
</>
);
}
export default function TablaConAyuda() {
return (
<section className="tarjeta">
<h2>Comisiones</h2>
<div className="caja-scroll baja">
<p>
La <Ayuda texto="Porcentaje extra sobre las ventas del trimestre">comisión</Ayuda> se
paga en el mes siguiente.
</p>
</div>
</section>
);
}

Figura 13. La burbuja está en body y sobresale de la caja con overflow: auto.
El efecto escribe style.left y style.top directamente sobre el nodo. Podrías guardar la posición en el estado, pero provocaría un render extra para un dato que solo le interesa al navegador, y estas propiedades no las gestiona React en ese nodo, así que no hay conflicto. useLayoutEffect hace que la burbuja nunca se pinte en la posición incorrecta. onFocus y onBlur la hacen accesible con teclado.
El elemento <dialog>: a veces no necesitas un portal
Los navegadores actuales tienen <dialog>, y con showModal() lo colocan en la capa superior (top layer), por encima de todo y fuera de cualquier overflow o transform. Y ya que showModal es una orden, hace falta una ref.
import { useEffect, useRef, useState, type ReactNode } from 'react';
interface DialogoProps {
abierto: boolean;
onCerrar: () => void;
children: ReactNode;
}
function Dialogo({ abierto, onCerrar, children }: DialogoProps) {
const nodo = useRef<HTMLDialogElement>(null);
useEffect(() => {
const dialogo = nodo.current;
if (!dialogo) return;
if (abierto && !dialogo.open) dialogo.showModal();
if (!abierto && dialogo.open) dialogo.close();
}, [abierto]);
return (
<dialog ref={nodo} className="dialogo" onClose={onCerrar}>
{children}
</dialog>
);
}
export default function FichaConDialog() {
const [abierto, setAbierto] = useState(false);
return (
<section className="tarjeta caja-recortada">
<h2>Misma tarjeta, sin portal</h2>
<button onClick={() => setAbierto(true)}>Abrir ficha</button>
<Dialogo abierto={abierto} onCerrar={() => setAbierto(false)}>
<h2>Ficha del empleado</h2>
<p>Departamento: Dirección. Localidad: Madrid.</p>
<button onClick={() => setAbierto(false)}>Cerrar</button>
</Dialogo>
</section>
);
}

Figura 14. Mismo overflow y transform que la figura 10, y sin portal: showModal lo saca a la capa superior.
Comprobado en Chromium: al abrir, el foco va al botón Cerrar; Escape cierra, dispara onClose, y el foco vuelve a Abrir ficha. Y todo sin escribir nada de eso.
Portal con div | <dialog> con showModal() | |
|---|---|---|
Escapa de overflow y transform | Sí | Sí |
| Atrapa el foco | Tienes que programarlo | Sí, el resto de la página queda inerte |
Escape | Tienes que programarlo | Sí |
| Devolver el foco al cerrar | Tienes que programarlo | Sí |
| Fondo | Un div más | ::backdrop en CSS |
| Avisos no modales, burbujas, menús | Adecuado | No es su función |
Para modales nuevos, empieza por <dialog>. Los portales siguen haciendo falta para avisos, burbujas, menús, y para colocar contenido de React en una zona concreta de la página, por ejemplo la cabecera de una aplicación que no es de React. Si necesitas varios destinos, añádelos a index.html, como <div id="modales"></div>.
Portales y servidor
createPortal necesita un nodo del DOM, y en el servidor no hay DOM. Al llamar a renderToString con un portal, 18.3.1 y 19.3.1 dan el mismo error:
Portals are not currently supported by the server renderer.
Si el contenedor no existe, en el navegador también falla: Target container is not a DOM element. En aplicaciones con renderizado en servidor, los portales se montan solo cuando el usuario ha abierto algo, o tras la hidratación (módulo 23).
8. flushSync
React agrupa los cambios de estado y los aplica cuando termina tu función (módulo 04). Es lo más eficiente. Pero ¿y si la siguiente línea necesita el DOM ya actualizado?
import { useRef, useState } from 'react';
import { flushSync } from 'react-dom';
export default function ListaDeTareas() {
const [tareas, setTareas] = useState(['Revisar pedidos', 'Llamar a Ana']);
const lista = useRef<HTMLUListElement>(null);
function agregarSinFlush() {
setTareas([...tareas, `Tarea ${tareas.length + 1}`]);
console.log('sin flushSync, filas en el DOM:', lista.current?.children.length);
lista.current?.lastElementChild?.scrollIntoView();
}
function agregarConFlush() {
flushSync(() => {
setTareas([...tareas, `Tarea ${tareas.length + 1}`]);
});
console.log('con flushSync, filas en el DOM:', lista.current?.children.length);
lista.current?.lastElementChild?.scrollIntoView();
}
return (
<section className="tarjeta">
<h2>Tareas</h2>
<div className="fila">
<button onClick={agregarSinFlush}>Añadir sin flushSync</button>
<button onClick={agregarConFlush}>Añadir con flushSync</button>
</div>
<ul ref={lista} className="caja-scroll baja">
{tareas.map((tarea) => <li key={tarea}>{tarea}</li>)}
</ul>
</section>
);
}
Pulsa cinco veces el primer botón y una el segundo. Esta es la consola, partiendo de dos tareas:
sin flushSync, filas en el DOM: 2
sin flushSync, filas en el DOM: 3
sin flushSync, filas en el DOM: 4
sin flushSync, filas en el DOM: 5
sin flushSync, filas en el DOM: 6
con flushSync, filas en el DOM: 8
Sin flushSync, el DOM todavía no tiene la fila nueva y lastElementChild es la anterior. La lista se queda siempre una fila por detrás: en nuestra prueba, a 21 píxeles del final. Con flushSync, React aplica el cambio antes de salir de la función, y la distancia es 0.
flushSync hace más cosas de lo que parece: antes de volver, también ejecuta los efectos de esa actualización. Con un efecto que anota el valor y una línea justo después de flushSync, el orden fue effect0, effect1, tras flushSync, igual en 18.3.1 y 19.3.1.
Casi siempre hay otra forma
La misma lista puede resolverse sin flushSync, con un efecto que dependa de los datos:
useEffect(() => {
lista.current?.lastElementChild?.scrollIntoView();
}, [tareas.length]);
Esta opción es preferible: da igual desde dónde se añada la tarea, y React sigue agrupando. Usa flushSync cuando la acción tiene sentido en ese evento y no siempre que cambian los datos, o cuando una API del navegador exige que el DOM esté listo en esa línea:
function imprimir() {
flushSync(() => setImprimiendo(true)); // Muestra la versión para imprimir
window.print(); // Bloquea y usa el DOM actual
setImprimiendo(false);
}
⚠️ Importante:
flushSyncrenuncia a la agrupación y fuerza un render completo, así que puede dañar el rendimiento. Además, no funciona dentro de un efecto o mientras React renderiza. Al probarlo en unuseEffect, las cuatro versiones avisan con este mensaje, y el cambio se aplica más tarde:flushSync was called from inside a lifecycle method. React cannot flush when React is already rendering.
9. Fragment Refs 🆕 19.3
🆕 Desde React 19.3. No existe en React 19.2 ni anteriores:
<Fragment ref>da un aviso y la ref no recibe nada. Si usas una versión anterior: añade un envoltorio con ref (en una tabla, un<tbody>por grupo), o un<div style={{ display: 'contents' }}>, que no afecta al diseño; también puedes usar una callback ref por hijo con unMap, como en la sección 4.
Un Fragment agrupa elementos sin añadir un nodo. Esa ventaja tenía un precio: no había nodo que referenciar. Para escuchar eventos, observar o dar foco a un grupo necesitabas un <div>, y a veces no puedes ponerlo. Dentro de <tbody> solo se admiten filas, y en flex o grid un envoltorio cambia el diseño.
Desde 19.3, <Fragment> acepta ref. Lo que recibes no es un nodo, sino un objeto FragmentInstance que actúa sobre los hijos de primer nivel del grupo. Debes escribir <Fragment>; <> no admite atributos.
Figura 15. La instancia representa a todas las filas del grupo y no añade nada al DOM.
Los métodos imitan a los de un nodo:
| Método | Efecto comprobado en Chromium |
|---|---|
focus() | Enfoca el primer elemento enfocable del grupo, aunque esté anidado. Si no hay ninguno, no hace nada |
focusLast() | Igual, con el último |
blur() | Quita el foco solo si está dentro del grupo |
addEventListener, removeEventListener | Escucha en cada hijo de primer nivel, y también en los que se añadan después |
dispatchEvent(evento) | Lanza el evento: lo reciben los oyentes del grupo y burbujea por el padre |
observeUsing, unobserveUsing | Conecta un IntersectionObserver o ResizeObserver a todos los hijos |
getClientRects() | Devuelve un rectángulo por cada hijo |
scrollIntoView() | Desplaza hasta el grupo |
getRootNode() | Devuelve el document o la raíz shadow |
Ejemplo: una tabla con un grupo de filas editables. Fíjate en el import type {}: sin él, TypeScript no ve los métodos, porque se declaran en @types/react-dom.
import { Fragment, useEffect, useRef, useState, type FragmentInstance } from 'react';
import type {} from 'react-dom'; // Trae los métodos de FragmentInstance a TypeScript
interface Empleado {
id: number;
nombre: string;
salario: number;
}
const iniciales: Empleado[] = [
{ id: 101, nombre: 'Luis Martínez', salario: 3200 },
{ id: 102, nombre: 'Carmen Ruiz', salario: 2900 },
];
export default function GrupoDeFilas() {
const [empleados, setEmpleados] = useState(iniciales);
const [clics, setClics] = useState(0);
const [visible, setVisible] = useState(false);
const [alto, setAlto] = useState(0);
const grupo = useRef<FragmentInstance>(null);
useEffect(() => {
const instancia = grupo.current;
if (!instancia) return;
function alClic() {
setClics((n) => n + 1);
}
const observador = new IntersectionObserver((entradas) => {
setVisible(entradas.some((entrada) => entrada.isIntersecting));
});
instancia.addEventListener('click', alClic);
instancia.observeUsing(observador);
return () => {
instancia.removeEventListener('click', alClic);
instancia.unobserveUsing(observador);
observador.disconnect();
};
}, []);
function medir() {
const rectangulos = grupo.current?.getClientRects() ?? [];
setAlto(Math.round(rectangulos.reduce((total, r) => total + r.height, 0)));
}
function agregarFila() {
const id = 100 + empleados.length + 1;
setEmpleados([...empleados, { id, nombre: `Empleado ${id}`, salario: 2000 }]);
}
return (
<section className="tarjeta">
<h2>Departamento de ventas</h2>
<div className="fila">
<button onClick={() => grupo.current?.focus()}>Foco al primero</button>
<button onClick={() => grupo.current?.focusLast()}>Foco al último</button>
<button onClick={medir}>Medir</button>
<button onClick={agregarFila}>Añadir fila</button>
</div>
<table className="tabla">
<thead>
<tr><th>Empleado</th><th>Salario</th></tr>
</thead>
<tbody>
<Fragment ref={grupo}>
{empleados.map((e) => (
<tr key={e.id}>
<td>{e.nombre}</td>
<td><input defaultValue={e.salario} aria-label={`Salario de ${e.nombre}`} /></td>
</tr>
))}
</Fragment>
<tr><td colSpan={2}>Esta fila queda fuera del grupo</td></tr>
</tbody>
</table>
<p data-testid="info">
Clics en el grupo: {clics}. Visible: {visible ? 'sí' : 'no'}. Alto: {alto} px.
</p>
</section>
);
}

Figura 16. Los escuchadores y observadores cubren también la fila añadida después; la última fila queda fuera.
Esto es lo que obtuvimos en el navegador:
Foco al primerolleva al campo de Luis Martínez, saltando la celda de texto, yFoco al últimoal de Carmen Ruiz.- Un clic en la primera fila suma 1. Un clic en la fila final no suma, porque está fuera del grupo.
- Tras Añadir fila, un clic en la fila nueva suma. El
addEventListenerse hizo cuando esa fila no existía, y aun así la alcanza. Este es el mayor valor frente a repartir refs a mano. Medirpasa de 90 a 134 píxeles.
Algunas observaciones más. Con grupo.current?.addEventListener, los eventos son nativos, no eventos de React: recibes un Event del navegador y debes quitarlo en la limpieza. Una callback ref también sirve, con limpieza incluida. Y en ejecución hay un método más, compareDocumentPosition, que en @types/react-dom 19.3.0 todavía no aparece, así que TypeScript no te dejará usarlo sin un cast.
Si solo vas a usar un método concreto, recuerda que hay alternativas más simples: para saber si el ratón está sobre un grupo, tienes onMouseEnter en cada hijo, y para enfocar el primer campo de un formulario, autoFocus. Reserva esta API para cuando un envoltorio es imposible o molesto.
10. Caso práctico: panel de empleados 🆕 19.0
🆕 Desde React 19.0. Este ejemplo usa
refcomo prop y la limpieza de refs. Si usas una versión anterior: envuelveBuscadorenforwardRef, y en la ref de cada fila borra delMapcuando el argumento seanullen lugar de devolver una limpieza.
Reunimos lo aprendido en un panel conectado a la API EMPRESA. Tiene un buscador accesible con /, un botón que lleva a la fila del mejor salario y una ficha en un portal. Arranca la API antes de abrirlo.
import {
useEffect,
useId,
useImperativeHandle,
useRef,
useState,
type Ref,
type RefObject,
} from 'react';
import { createPortal } from 'react-dom';
const API = 'http://localhost:3000/api';
interface Empleado {
empId: number;
nombre: string;
apellidos: string;
email: string;
oficio: string;
salario: number;
}
interface BuscadorHandle {
enfocar: () => void;
vaciar: () => void;
}
interface BuscadorProps {
valor: string;
onCambio: (valor: string) => void;
ref?: Ref<BuscadorHandle>;
}
// Un campo que ofrece dos acciones, y no todo su <input>.
function Buscador({ valor, onCambio, ref }: BuscadorProps) {
const entrada = useRef<HTMLInputElement>(null);
useImperativeHandle(ref, () => ({
enfocar() {
entrada.current?.focus();
entrada.current?.select();
},
vaciar() {
onCambio('');
entrada.current?.focus();
},
}), [onCambio]);
return (
<input
ref={entrada}
type="search"
value={valor}
placeholder="Pulsa / para buscar"
aria-label="Buscar empleados"
onChange={(e) => onCambio(e.target.value)}
/>
);
}
interface FichaProps {
empleado: Empleado;
onCerrar: () => void;
}
function Ficha({ empleado, onCerrar }: FichaProps) {
const dialogo = useRef<HTMLDivElement>(null);
const idTitulo = useId();
useEffect(() => {
const anterior = document.activeElement;
dialogo.current?.focus();
return () => {
if (anterior instanceof HTMLElement) anterior.focus();
};
}, []);
return createPortal(
<div className="modal-fondo" onClick={onCerrar}>
<div
ref={dialogo}
className="modal"
role="dialog"
aria-modal="true"
aria-labelledby={idTitulo}
tabIndex={-1}
onClick={(e) => e.stopPropagation()}
onKeyDown={(e) => {
if (e.key === 'Escape') onCerrar();
}}
>
<h2 id={idTitulo}>{empleado.nombre} {empleado.apellidos}</h2>
<p>{empleado.oficio}</p>
<p>{empleado.email}</p>
<p>{empleado.salario.toLocaleString('es-ES')} € al mes</p>
<button onClick={onCerrar}>Cerrar</button>
</div>
</div>,
document.body,
);
}
function useAtajoBusqueda(buscador: RefObject<BuscadorHandle | null>) {
useEffect(() => {
function alPulsar(e: KeyboardEvent) {
const etiqueta = (e.target as HTMLElement).tagName;
if (e.key === '/' && etiqueta !== 'INPUT') {
e.preventDefault();
buscador.current?.enfocar();
}
}
document.addEventListener('keydown', alPulsar);
return () => document.removeEventListener('keydown', alPulsar);
}, [buscador]);
}
export default function PanelEmpleados() {
const [empleados, setEmpleados] = useState<Empleado[]>([]);
const [error, setError] = useState('');
const [texto, setTexto] = useState('');
const [activoId, setActivoId] = useState<number | null>(null);
const [fichaId, setFichaId] = useState<number | null>(null);
const buscador = useRef<BuscadorHandle>(null);
const filas = useRef(new Map<number, HTMLTableRowElement>());
useAtajoBusqueda(buscador);
useEffect(() => {
const control = new AbortController();
fetch(`${API}/empleados?tamano=100&orden=apellidos`, { signal: control.signal })
.then((respuesta) => {
if (!respuesta.ok) throw new Error(`Error ${respuesta.status}`);
return respuesta.json();
})
.then((datos) => setEmpleados(datos.contenido))
.catch((e: Error) => {
if (e.name !== 'AbortError') setError(e.message);
});
return () => control.abort();
}, []);
const busqueda = texto.toLowerCase();
const visibles = empleados.filter((e) =>
`${e.nombre} ${e.apellidos}`.toLowerCase().includes(busqueda),
);
const ficha = empleados.find((e) => e.empId === fichaId);
function irAlMejorPagado() {
if (visibles.length === 0) return;
const mejor = visibles.reduce((a, b) => (b.salario > a.salario ? b : a));
setActivoId(mejor.empId);
filas.current.get(mejor.empId)?.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
if (error) return <p className="error">{error}</p>;
return (
<section className="tarjeta">
<h2>Panel de empleados</h2>
<div className="fila">
<Buscador ref={buscador} valor={texto} onCambio={setTexto} />
<button onClick={() => buscador.current?.vaciar()}>Vaciar</button>
<button onClick={irAlMejorPagado}>Mejor salario</button>
</div>
<div className="caja-scroll">
<table className="tabla">
<thead>
<tr><th>Empleado</th><th>Salario</th><th></th></tr>
</thead>
<tbody>
{visibles.map((e) => (
<tr
key={e.empId}
data-activa={e.empId === activoId}
ref={(nodo) => {
if (nodo) filas.current.set(e.empId, nodo);
return () => {
filas.current.delete(e.empId);
};
}}
>
<td>{e.apellidos}, {e.nombre}</td>
<td>{e.salario.toLocaleString('es-ES')} €</td>
<td><button onClick={() => setFichaId(e.empId)}>Ficha</button></td>
</tr>
))}
</tbody>
</table>
</div>
<p>{visibles.length} de {empleados.length} empleados</p>
{ficha && <Ficha empleado={ficha} onCerrar={() => setFichaId(null)} />}
</section>
);
}

Figura 17. El botón Mejor salario desplaza la caja hasta la fila y la resalta.

Figura 18. La ficha se monta en body. Al cerrarla, el foco vuelve al botón de su fila.
Dónde está cada pieza del módulo:
| Pieza | Dónde se usa |
|---|---|
| Ref a un nodo | entrada en Buscador, dialogo en Ficha |
useImperativeHandle | Buscador expone enfocar y vaciar, nunca el <input> |
ref como prop | Buscador |
| Efecto con una ref | useAtajoBusqueda recibe un RefObject y lee .current al pulsar |
| Callback ref con limpieza | Cada fila se registra en filas |
scrollIntoView | irAlMejorPagado |
| Portal | Ficha |
| Foco de vuelta | Limpieza del efecto de Ficha |
Lo comprobamos en el navegador con la API en marcha: / lleva al buscador; gar deja una fila de 24; Vaciar recupera las 24 y deja el foco en el campo; Mejor salario marca a Ana García; y Escape cierra la ficha y devuelve el foco al botón Ficha.
useAtajoBusqueda es una función propia que usa hooks; este tipo de función se estudia en el módulo 12. Fíjate también en que Escape se atiende con onKeyDown en el propio diálogo, y no con un escuchador en document. Funciona porque el foco está dentro. Si pudiera salir, necesitarías el escuchador de la sección 7.
11. Errores frecuentes
| Error | Síntoma | Solución |
|---|---|---|
Usar ref.current en el JSX | La pantalla no cambia | Usa estado |
Escribir ref.current en el render | Valores dobles en Strict Mode | Muévelo a un evento o efecto |
Leer ref.current al montar en el render | Es null | Léelo en un efecto o evento |
Olvidar ?. | Cannot read properties of null | El nodo puede no existir; compruébalo |
Usar ref.current en la limpieza de un efecto | Es null | Cópialo a una variable al principio del efecto |
useRef dentro de map | Error de reglas de hooks | Una ref con Map y callback refs |
remove() o innerHTML en nodos de React | Error de removeChild y pantalla en blanco | Controla la estructura con estado |
| Callback ref con retorno implícito | TS2322 en React 19 | Añade llaves |
| Callback ref en línea con trabajo caro | Se repite en cada render | Función estable o fuera del componente |
Olvidar disconnect u off | Fugas de memoria | Limpieza en efecto o callback ref |
useRef(new Clase()) | Crea un objeto por render | Inicialización perezosa |
Exponer todo con useImperativeHandle | Código imposible de seguir | Usa props; expón lo mínimo |
| Olvidar que los eventos del portal suben | Clics que activan al padre | stopPropagation en la raíz del portal |
z-index altísimo en lugar de portal | Sigue recortado | Portal o <dialog> |
document.body en renderizado en servidor | document is not defined | Monta el portal solo en cliente |
flushSync en un efecto | Aviso lifecycle method | Quítalo o usa un temporizador |
<></> con ref | Error de sintaxis | Escribe <Fragment ref={...}> |
Métodos de FragmentInstance sin tipos | TypeScript no los conoce | import type {} from 'react-dom' |
12. Ejercicios
Las soluciones usan las clases CSS de la sección 1.
Ejercicio 1: Contador de renders
Muestra cuántas veces se ha renderizado un componente con un campo de texto. Hazlo sin estado, para no provocar más renders, y respetando la regla de la sección 2.
Ver solución
import { useEffect, useRef, useState } from 'react';
export default function ContadorDeRenders() {
const [texto, setTexto] = useState('');
const renders = useRef(0);
const salida = useRef<HTMLSpanElement>(null);
// Sin lista de dependencias: se ejecuta tras cada render.
useEffect(() => {
renders.current += 1;
if (salida.current) salida.current.textContent = String(renders.current);
});
return (
<section className="tarjeta">
<input value={texto} onChange={(e) => setTexto(e.target.value)} />
<p>Renders: <span ref={salida} data-testid="renders">0</span></p>
</section>
);
}
Un efecto sin lista de dependencias corre tras cada render, y es un lugar permitido para escribir en una ref. Para ver el número hace falta el DOM, ya que la ref no llega al JSX, y textContent es seguro porque React no gestiona hijos en ese <span>: su contenido fijo no cambia nunca entre renders.
En la prueba, en desarrollo, el valor inicial es 2, porque Strict Mode ensaya el efecto, y sube a 5 tras escribir tres letras.
Ejercicio 2: Clic fuera
Crea un menú Acciones que se cierre al pulsar fuera de él, pero no al pulsar en sus opciones.
Ver solución
import { useEffect, useRef, useState } from 'react';
export default function MenuAcciones() {
const [abierto, setAbierto] = useState(false);
const contenedor = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!abierto) return;
function alPulsar(e: PointerEvent) {
if (!contenedor.current?.contains(e.target as Node)) setAbierto(false);
}
document.addEventListener('pointerdown', alPulsar);
return () => document.removeEventListener('pointerdown', alPulsar);
}, [abierto]);
return (
<section className="tarjeta" style={{ minHeight: 160 }}>
<div ref={contenedor}>
<button onClick={() => setAbierto(!abierto)}>Acciones</button>
{abierto && (
<ul data-testid="menu">
<li>Editar</li>
<li>Duplicar</li>
<li>Borrar</li>
</ul>
)}
</div>
<p>Pulsa aquí, fuera del menú.</p>
</section>
);
}
contains dice si un nodo está dentro de otro. Usamos pointerdown y no click por dos motivos: funciona igual con ratón y pantalla táctil, y se dispara antes que el click del botón Acciones. Como la comprobación incluye ese botón dentro de contenedor, no cierra y reabre a la vez.
El escuchador solo existe mientras el menú está abierto, gracias a if (!abierto) return. En la prueba: abrir, pulsar una opción (sigue abierto), pulsar fuera (se cierra), y abrir y cerrar con el botón.
Recuerda que contains mira el DOM real: si el menú fuera un portal, un clic en él contaría como fuera.
Ejercicio 3: Evitar el doble envío
Un botón Guardar tarda medio segundo. Aunque el usuario pulse tres veces muy deprisa, solo debe haber un envío.
Ver solución
import { useRef, useState } from 'react';
function guardarEnServidor() {
return new Promise<void>((resolver) => setTimeout(resolver, 500));
}
export default function BotonGuardar() {
const [guardando, setGuardando] = useState(false);
const [envios, setEnvios] = useState(0);
const enCurso = useRef(false);
async function guardar() {
if (enCurso.current) return; // Segundo clic: la ref ya está a true.
enCurso.current = true;
setGuardando(true);
setEnvios((n) => n + 1);
try {
await guardarEnServidor();
} finally {
enCurso.current = false;
setGuardando(false);
}
}
return (
<section className="tarjeta">
<button onClick={guardar}>{guardando ? 'Guardando...' : 'Guardar'}</button>
<p data-testid="envios">Envíos: {envios}</p>
</section>
);
}
¿Por qué no basta con el estado guardando? Porque cada render trabaja con su instantánea. Tres clics en el mismo render leerían guardando === false las tres veces. La ref cambia al momento, así que el segundo clic ya la ve a true. Lanzamos tres click() seguidos en el navegador, y el contador se quedó en 1. Tras terminar, otro clic subió a 2.
El estado se mantiene para mostrar el texto Guardando…. Es un buen ejemplo de los dos juntos: la ref decide y el estado muestra. finally garantiza que se desbloquea también si falla. En el módulo 16 verás que useActionState resuelve parte de esto.
Ejercicio 4: Chat con scroll inteligente
Una lista de mensajes baja sola al llegar uno nuevo, pero solo si el usuario ya estaba abajo. Si ha subido a leer, no debe moverse.
Ver solución
import { useEffect, useRef, useState } from 'react';
export default function Chat() {
const [mensajes, setMensajes] = useState(['Hola', 'Buenos días']);
const caja = useRef<HTMLDivElement>(null);
const pegadoAlFinal = useRef(true);
function alDesplazar() {
const nodo = caja.current;
if (!nodo) return;
const distancia = nodo.scrollHeight - nodo.scrollTop - nodo.clientHeight;
pegadoAlFinal.current = distancia < 24;
}
useEffect(() => {
const nodo = caja.current;
if (nodo && pegadoAlFinal.current) nodo.scrollTop = nodo.scrollHeight;
}, [mensajes]);
function recibir() {
setMensajes((lista) => [...lista, `Mensaje ${lista.length + 1}`]);
}
return (
<section className="tarjeta">
<button onClick={recibir}>Recibir mensaje</button>
<div ref={caja} className="caja-scroll baja" onScroll={alDesplazar} data-testid="chat">
{mensajes.map((texto) => <p key={texto}>{texto}</p>)}
</div>
</section>
);
}
pegadoAlFinal es una ref porque cambia con cada evento de scroll y no afecta a lo que se pinta. La distancia se calcula con la fórmula clásica, con 24 píxeles de margen, ya que un valor exacto de 0 falla con facilidad por los decimales. El efecto se ejecuta tras añadir mensajes, cuando scrollHeight ya incluye el nuevo.
En la prueba, tras nueve mensajes la distancia al final es 0. Después de subir manualmente, un mensaje nuevo deja scrollTop en 0.
Ejercicio 5: Observar con una callback ref 🆕 19.0
🆕 Desde React 19.0. La limpieza devuelta por la callback ref no existe en React 18.3. Si usas una versión anterior: guarda cada observador en un
Mapy desconéctalo cuando la función recibanull.
Varias tarjetas deben guardar su ancho en data-ancho y dejar de observar al desmontarse, sin un efecto por tarjeta.
Ver solución
import { useState } from 'react';
function observarAncho(nodo: HTMLDivElement) {
const observador = new ResizeObserver(([entrada]) => {
nodo.dataset.ancho = String(Math.round(entrada.contentRect.width));
});
observador.observe(nodo);
return () => observador.disconnect();
}
export default function Tarjetas() {
const [mostrar, setMostrar] = useState(true);
return (
<section className="tarjeta">
<button onClick={() => setMostrar(!mostrar)}>Mostrar u ocultar</button>
{mostrar && <div ref={observarAncho} className="panel-medido">Ventas</div>}
{mostrar && <div ref={observarAncho} className="panel-medido">Compras</div>}
</section>
);
}
Toda la lógica cabe en una función fuera del componente. Al estar fuera, es la misma en todos los renders y React no la vuelve a enlazar. La misma función sirve para todas las tarjetas, y su limpieza desconecta el observador correspondiente, que queda en una clausura. Comparado con useEffect, esta forma funciona aunque el nodo aparezca o desaparezca mediante una condición.
En la prueba, ambas tarjetas reciben data-ancho="596", y al ocultar y mostrar no hay errores en consola.
Ejercicio 6: Carrusel controlado desde fuera 🆕 19.0
🆕 Desde React 19.0. La solución recibe
refcomo prop. Si usas una versión anterior: envuelveCarruselenforwardRef.
Crea un Carrusel que guarde su índice y exponga siguiente() y anterior(). Los botones están en el padre. Debe dar la vuelta en los extremos.
Ver solución
import { useImperativeHandle, useRef, useState, type Ref } from 'react';
export interface CarruselHandle {
siguiente: () => void;
anterior: () => void;
}
interface CarruselProps {
elementos: string[];
ref?: Ref<CarruselHandle>;
}
function Carrusel({ elementos, ref }: CarruselProps) {
const [indice, setIndice] = useState(0);
const total = elementos.length;
useImperativeHandle(ref, () => ({
siguiente: () => setIndice((i) => (i + 1) % total),
anterior: () => setIndice((i) => (i - 1 + total) % total),
}), [total]);
return <p className="grande" data-testid="carrusel">{elementos[indice]}</p>;
}
export default function Escaparate() {
const carrusel = useRef<CarruselHandle>(null);
return (
<section className="tarjeta">
<Carrusel ref={carrusel} elementos={['Teclado', 'Ratón', 'Monitor']} />
<div className="fila">
<button onClick={() => carrusel.current?.anterior()}>Anterior</button>
<button onClick={() => carrusel.current?.siguiente()}>Siguiente</button>
</div>
</section>
);
}
(i - 1 + total) % total evita el resto negativo: en JavaScript, -1 % 3 vale -1. Las funciones usan la forma actualizadora, así que solo dependen de total. En la prueba, Anterior desde el primero muestra Monitor, y dos veces Siguiente llevan a Ratón.
Pregúntate si esta es la mejor opción: si el padre necesitara mostrar el índice, habría que elevar el estado (módulo 04) y quitar el handle.
Ejercicio 7: Avisos con portal
Crea avisos que aparezcan en la esquina y desaparezcan a los 3 segundos, aunque el botón esté dentro de una tarjeta con overflow: hidden.
Ver solución
import { useCallback, useEffect, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
interface Aviso {
id: number;
texto: string;
}
function Toast({ aviso, onFin }: { aviso: Aviso; onFin: (id: number) => void }) {
useEffect(() => {
const temporizador = setTimeout(() => onFin(aviso.id), 3000);
return () => clearTimeout(temporizador);
}, [aviso.id, onFin]);
return <div className="toast" role="status">{aviso.texto}</div>;
}
export default function Avisos() {
const [avisos, setAvisos] = useState<Aviso[]>([]);
const siguienteId = useRef(1);
function avisar(texto: string) {
const id = siguienteId.current;
siguienteId.current += 1;
setAvisos((lista) => [...lista, { id, texto }]);
}
const quitar = useCallback((id: number) => {
setAvisos((lista) => lista.filter((a) => a.id !== id));
}, []);
return (
<section className="tarjeta" style={{ overflow: 'hidden' }}>
<button onClick={() => avisar('Pedido 5001 guardado')}>Guardar pedido</button>
{createPortal(
<div className="toasts">
{avisos.map((a) => <Toast key={a.id} aviso={a} onFin={quitar} />)}
</div>,
document.body,
)}
</section>
);
}

Figura 19. Los avisos viven en body, y cada uno tiene su propio temporizador.
El temporizador está en cada Toast: si el aviso desaparece antes, su limpieza lo cancela. siguienteId es una ref porque no se pinta. useCallback da a quitar una identidad estable; sin él, cada aviso nuevo reiniciaría los temporizadores de todos, y se quedarían sin desaparecer si llegaran avisos seguidos. role="status" hace que los lectores de pantalla lo anuncien.
En la prueba, con dos avisos separados 0,7 s, primero desaparece uno y después el otro.
Ejercicio 8: Foco en un grupo 🆕 19.3
🆕 Desde React 19.3. Usa Fragment Refs, que no existen en React 19.2 ni anteriores. Si usas una versión anterior: envuelve el grupo en un
<div ref={...}>y usaquerySelector('input')?.focus().
Una sección plegable debe dar el foco al primer campo al abrirse, sin añadir un nodo envoltorio y sin saber qué campos contiene.
Ver solución
import { Fragment, useEffect, useRef, useState, type FragmentInstance } from 'react';
import type {} from 'react-dom';
export default function SeccionPlegable() {
const [abierta, setAbierta] = useState(false);
const grupo = useRef<FragmentInstance>(null);
useEffect(() => {
if (abierta) grupo.current?.focus();
}, [abierta]);
return (
<section className="tarjeta">
<button onClick={() => setAbierta(!abierta)} aria-expanded={abierta}>
Datos de contacto
</button>
{abierta && (
<Fragment ref={grupo}>
<p>Rellena los campos.</p>
<input aria-label="Teléfono" placeholder="Teléfono" />
<input aria-label="Correo" placeholder="Correo" />
</Fragment>
)}
</section>
);
}
El efecto corre tras el commit, cuando el grupo ya existe. focus() salta el párrafo, que no es enfocable, y llega a Teléfono. Así, puedes cambiar los campos sin tocar el efecto. aria-expanded informa del estado a los lectores de pantalla.
Ejercicio 9 (propuesto): Trampa de foco
Añade al modal de la sección 7 una trampa de foco: Tab en el último elemento debe volver al primero, y Mayús+Tab en el primero, al último. Pista: busca los enfocables con querySelectorAll en dialogo.current, y usa onKeyDown. Compáralo con <dialog>.
Ejercicio 10 (propuesto): Burbuja que se da la vuelta
Mejora Ayuda para que aparezca debajo cuando no quepa encima (origen.top menor que el alto de la burbuja) y que no se salga por la derecha. Hazlo todo en useLayoutEffect.
Ejercicio 11 (propuesto): Índice lateral
Crea una página larga con varias secciones y un índice. Al pulsar en una entrada, desplázate a su sección. Con un IntersectionObserver y un Map de refs, resalta la sección visible. Si tienes 19.3, prueba observeUsing en un Fragment por sección.
Resumen
| Concepto | API | Idea clave |
|---|---|---|
| Ref | useRef(inicial) | { current } que sobrevive y no provoca render |
| Ref o estado | Si se ve en pantalla, estado; si solo lo usa tu código, ref | |
| Regla | react(refs) en oxlint | No leas ni escribas current en el render |
| Inicialización perezosa | if (ref.current === null) | La única excepción a la regla |
| Argumento obligatorio | useRef(valor) 🔄 19.0 | RefObject único y modificable |
| Ref a un nodo | <input ref={r} /> | null en el render; nodo desde el commit |
| Foco | focus(), select() | Devuélvelo al cerrar lo que lo tomó |
| Desplazamiento | scrollIntoView, scrollTo | Sobre el elemento o sobre la caja |
| Medir | getBoundingClientRect, ResizeObserver | Una medida, o avisos de cambios |
| Código externo | Ref y efecto | Canvas, mapas y gráficos |
| Callback ref | ref={(nodo) => ...} | Se enlaza cuando el nodo aparece |
| Limpieza de refs | return () => ... 🆕 19.0 | Sustituye a la llamada con null |
| Strict Mode | Ensayo de refs 🔄 19.0 | Enlaza, suelta y vuelve a enlazar |
| Listas | useRef(new Map()) | Una ref con muchos nodos |
| Ref en componentes | ref como prop 🆕 19.0 | forwardRef en versiones anteriores |
| Interfaz reducida | useImperativeHandle | Expón solo acciones puntuales |
| Portal | createPortal(hijos, nodo) | Otro lugar del DOM, mismo árbol de React |
| Eventos en portales | Los de React suben por el árbol de React | |
| Modal nativo | <dialog>, showModal() | Capa superior, foco y Escape incluidos |
| Síncrono | flushSync(fn) | DOM listo en la línea siguiente; úsalo poco |
| Grupo de nodos | <Fragment ref> 🆕 19.3 | Un FragmentInstance, sin envoltorio |