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 useRef valores 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 createPortal y 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 flushSync y conocer su coste
  • Actuar sobre un grupo de nodos sin envoltorio con Fragment Refs

💡 Consejo: useRef, las refs a nodos, useImperativeHandle, createPortal y flushSync existen desde mucho antes de React 18. Lo marcado en este módulo es lo que ha cambiado: ref como 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:

  1. Los clics en la ref no generan ninguna línea render. El valor cambia y React no hace nada.
  2. 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.

Con estado, el cambio pasa por un nuevo render hasta la pantalla; con una ref, el valor cambia al instante y no ocurre nada más

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/react 18 se podía escribir useRef<number>(), y los tipos distinguían un RefObject de solo lectura y un MutableRefObject. Con los tipos de 19, el argumento es obligatorio y solo queda RefObject, con current siempre modificable. Si usas una versión anterior: pasa siempre un argumento, por ejemplo useRef<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>
  );
}

Cronómetro que marca 2,4 segundos con los botones Arrancar y Parar

Figura 2. Tras pulsar Parar, el tiempo se detiene en 2,4 s.

Fíjate en tres detalles:

  • parar funciona porque lee intervalo.current, que siempre es el valor más reciente. Si el identificador estuviera en una variable local, parar vería la del render en el que se creó.
  • arrancar empieza llamando a parar. Así, pulsar dos veces Arrancar no deja un intervalo huérfano que nadie podría detener.
  • useEffect(() => parar, []) devuelve parar como 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, useEffectEvent resuelve 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

Línea de tiempo: la ref es null en el render y el commit, tiene el nodo desde la asignación, y al desmontar se suelta antes de la limpieza de useEffect

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} />}, current será null mientras no se muestre. Por eso usamos ?.. TypeScript te obliga, porque el tipo es HTMLInputElement | 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>
  );
}

Panel con texto largo y el mensaje El panel mide 620 por 108 píxeles

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>
  );
}

Gráfico de barras en canvas con la plantilla de seis departamentos

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 con null en lugar de cada línea limpiar.

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/react 18, una callback ref podía devolver cualquier valor. Con los de 19, solo void o 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 Map devolviendo una función, algo que React 18.3 ignora. Si usas una versión anterior: borra la entrada cuando la función reciba null, 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>
  );
}

Directorio con scroll y la fila del empleado 120 resaltada en el centro de la lista

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: ref no llega como prop a un componente función. Si usas una versión anterior: envuelve el componente en forwardRef, 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>
  );
}

Formulario con el campo Correo enfocado y el mensaje El correo no es válido

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 ref como prop. Si usas una versión anterior: envuelve el componente en forwardRef y pasa a useImperativeHandle la 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>
  );
}

Formulario de facturación con NIF y código postal marcados como incorrectos

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. CampoValidadoHandle describe lo que se expone. El padre lo usa en useRef<CampoValidadoHandle>(null), y TypeScript solo le deja llamar a esas funciones.
  • Dependencias. validar lee valor, así que está en la lista. Sin ella, validar usarí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.
  • animate es 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.

A la izquierda, el árbol de React con el modal dentro de Tarjeta; a la derecha, el DOM con el modal como hijo de body

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>
  );
}

Sin portal, el modal y su fondo quedan recortados dentro de la tarjeta

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

Con portal, el fondo cubre toda la ventana y el modal aparece centrado

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" y aria-modal indican a los lectores de pantalla qué es, y aria-labelledby le da nombre. useId genera el identificador (módulo 08).
  • Foco. Al abrir, el foco entra en el diálogo; tabIndex={-1} permite darle el foco a un div. Al cerrar, vuelve a donde estaba. En la prueba, tras pulsar Escape, el foco regresa al botón que lo abrió.
  • Escape cierra, con un escuchador en document que se quita en la limpieza.
  • Dos efectos. El foco se guarda en un efecto con [], y Escape en 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.
  • stopPropagation en 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>
  );
}

Tarjeta con el registro de eventos y, abajo a la derecha, el aviso del portal que muestra el contexto VENTAS

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:

  1. Un onClick en un padre recibirá los clics del modal. Si una fila de tabla abre un modal y tiene onClick, un clic dentro del modal volverá a activarla. Se evita con stopPropagation en el fondo del modal.
  2. 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>
  );
}

La burbuja de ayuda sobresale por encima de la caja con scroll, sin recortarse

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>
  );
}

Diálogo nativo centrado y con fondo oscuro sobre toda la ventana, aunque está dentro de la tarjeta recortada

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: flushSync renuncia 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 un useEffect, 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 un Map, 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.

El objeto FragmentInstance apunta a tres filas de un tbody sin ningún nodo envoltorio; una cuarta fila queda fuera

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>
  );
}

Tabla con tres filas de empleados con salario editable y el mensaje Clics en el grupo 2, Visible sí, Alto 134 píxeles

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 primero lleva al campo de Luis Martínez, saltando la celda de texto, y Foco al último al 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 addEventListener se hizo cuando esa fila no existía, y aun así la alcanza. Este es el mayor valor frente a repartir refs a mano.
  • Medir pasa 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 ref como prop y la limpieza de refs. Si usas una versión anterior: envuelve Buscador en forwardRef, y en la ref de cada fila borra del Map cuando el argumento sea null en 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>
  );
}

Panel con buscador y tabla de empleados, con la fila de Ana García resaltada

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

Ficha de Ana García en un modal sobre el panel oscurecido

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 Map y desconéctalo cuando la función reciba null.

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 ref como prop. Si usas una versión anterior: envuelve Carrusel en forwardRef.

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>
  );
}

Dos avisos apilados en la esquina inferior derecha de la ventana

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 usa querySelector('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