La lección anterior dejó dos cabos sueltos. El primero es técnico: el temporizador de DisponibilidadEstacion devolvía un identificador que había que guardar en algún sitio para poder cancelarlo, y una variable normal no sirve porque se pierde en cada render, mientras que el estado provocaría un repintado inútil. El segundo viene de más atrás, de 03-05: allí se mencionó que se puede leer un campo de formulario accediendo directamente al nodo del DOM, y se aplazó la explicación. useRef resuelve las dos cosas, porque en realidad son la misma: es una caja que sobrevive a los renders sin formar parte de ellos. En esta lección verás sus dos usos —referencia a nodos del DOM y valor mutable de instancia—, cuándo se puede leer y cuándo no, cómo pasar referencias a tus propios componentes en React 19 (donde ref ya es una prop normal), las referencias de callback para elementos dinámicos, y la frontera exacta entre lo que merece useRef y lo que merece useState.

Contenido

  1. El problema: recordar sin repintar
  2. useRef, la caja mutable
  3. useState, useRef y variable normal: tabla comparativa
  4. Uso 1: referencia a un nodo del DOM
  5. Cuatro casos reales en CicloUrbano
  6. Cuándo está disponible ref.current
  7. Uso 2: valor mutable de instancia
  8. La regla de oro: no leer ni escribir current durante el render
  9. Cerrando la deuda de 03-05: ref frente a FormData
  10. Pasar referencias a componentes propios en React 19
  11. Referencias de callback para elementos dinámicos
  12. Cuándo NO usar useRef

  1. El problema: recordar sin repintar

Imagina un cronómetro en el panel de operario de CicloUrbano: arranca al pulsar «Iniciar revisión» y se para al pulsar «Finalizar». Necesitas guardar el identificador que devuelve setInterval para poder cancelarlo. Veamos las dos opciones que ya conoces y por qué ninguna vale.

// ❌ Opción A: variable normal. Se pierde en cada render.
function Cronometro() {
  const [segundos, setSegundos] = useState(0);
  let identificador = null;                  // nace y muere con cada render

  function manejarIniciar() {
    identificador = setInterval(() => setSegundos((p) => p + 1), 1000);
  }

  function manejarParar() {
    clearInterval(identificador);            // aquí ya vale null: no para nada
  }
}

El primer setSegundos provoca un render, la función del componente se ejecuta otra vez y identificador vuelve a valer null. El cronómetro no se puede parar.

// ❌ Opción B: estado. Funciona, pero repinta sin necesidad.
const [identificador, setIdentificador] = useState(null);

Ahora sí sobrevive, pero cada setIdentificador provoca un render completo del componente para guardar un número que no aparece en ninguna parte de la pantalla. Es trabajo tirado, y en componentes grandes se nota.

Lo que necesitamos es una tercera cosa: algo que persista como el estado pero que no dispare renders como una variable normal. Eso es exactamente useRef.

  1. useRef, la caja mutable

const referencia = useRef(valorInicial);

useRef devuelve siempre el mismo objeto, render tras render, con una única propiedad:

{ current: valorInicial }

Y ahí acaba la magia. No hay nada más. Tres propiedades que definen su comportamiento:

  • El objeto devuelto es estable. React nunca lo sustituye; en el render 1 y en el render 500 es la misma referencia. Por eso no hace falta ponerlo en las dependencias de un efecto (05-02).
  • current es mutable y lo cambias tú. No hay actualizador: se asigna directamente, referencia.current = algo. React no interviene.
  • Cambiar current no provoca ningún render. React ni se entera.

El cronómetro, resuelto:

// src/componentes/CronometroRevision.jsx
import { useState, useRef } from 'react';

function CronometroRevision({ bicicleta }) {
  const [segundos, setSegundos] = useState(0);
  const identificadorIntervalo = useRef(null);   // ← la caja que sobrevive

  function manejarIniciar() {
    if (identificadorIntervalo.current !== null) return;   // ya está en marcha
    identificadorIntervalo.current = setInterval(() => {
      setSegundos((previos) => previos + 1);
    }, 1000);
  }

  function manejarParar() {
    clearInterval(identificadorIntervalo.current);
    identificadorIntervalo.current = null;
  }

  return (
    <>
      <p>Revisión de {bicicleta.modelo}: {segundos} s</p>
      <button type="button" onClick={manejarIniciar}>Iniciar revisión</button>
      <button type="button" onClick={manejarParar}>Finalizar</button>
    </>
  );
}

export default CronometroRevision;

Fíjate en el reparto: segundos es estado porque se ve en pantalla y debe repintar; identificadorIntervalo es referencia porque es fontanería interna que nadie mira.

  1. useState, useRef y variable normal: tabla comparativa

Variable normal (let x) useRef useState
¿Persiste entre renders? ❌ No, se reinicia ✅ Sí ✅ Sí
¿Al cambiarla se repinta? ❌ No ❌ No ✅ Sí
Cómo se cambia x = valor ref.current = valor setX(valor)
¿Se puede leer durante el render? ✅ Sí ⚠️ No se debe ✅ Sí
¿Se puede escribir durante el render? ✅ Sí ❌ No ❌ No
¿Va en las dependencias de un efecto? ✅ Sí (es reactiva) ❌ No (es estable) ✅ Sí
Para qué sirve Cálculos locales de un render Recordar sin repintar; acceder al DOM Datos que se ven en pantalla

La frontera es una sola pregunta: ¿este dato aparece en lo que se dibuja? Si la respuesta es sí, es estado. Si es no, probablemente sea una referencia.

  1. Uso 1: referencia a un nodo del DOM

Este es el uso más visible. Se declara la referencia, se pasa como atributo ref de un elemento de JSX, y React coloca ahí el nodo real del navegador.

import { useRef } from 'react';

function EjemploFoco() {
  const campo = useRef(null);          // 1. arranca a null

  function manejarEnfocar() {
    campo.current.focus();             // 3. campo.current ES el <input> real
  }

  return (
    <>
      <input ref={campo} type="text" />   {/* 2. React pone el nodo en current */}
      <button type="button" onClick={manejarEnfocar}>Ir al campo</button>
    </>
  );
}

El flujo, con precisión:

flowchart TD
    A["Render: useRef(null) devuelve { current: null }"] --> B["JSX declara ref={campo}"]
    B --> C["Commit: React crea/actualiza el nodo del DOM"]
    C --> D["React asigna campo.current = nodo real"]
    D --> E["Efectos y manejadores ya pueden usar campo.current"]
    E --> F{"¿El elemento se desmonta?"}
    F -- "Sí" --> G["React asigna campo.current = null"]
    style D fill:#dcfce7
    style G fill:#fecaca

Es importante entender que React se encarga de rellenar y vaciar current. Tú no lo asignas nunca en este uso: solo lo lees.

  1. Cuatro casos reales en CicloUrbano

Enfocar el primer campo del formulario de reserva

Cuando el usuario pulsa «Reservar» en una tarjeta, el formulario aparece y lo cortés es dejar el cursor listo. Además es un requisito de accesibilidad (03-06): quien navega con teclado no debería tener que tabular hasta allí.

// src/componentes/FormularioReserva.jsx (fragmento)
import { useRef, useEffect } from 'react';

function FormularioReserva({ alCrearReserva, usuarioId = 'usr-01' }) {
  const campoBicicleta = useRef(null);

  useEffect(() => {
    campoBicicleta.current?.focus();   // al montarse, el foco va al primer campo
  }, []);

  return (
    <form noValidate onSubmit={manejarEnvio}>
      <label htmlFor="bicicletaId">Bicicleta</label>
      <select id="bicicletaId" name="bicicletaId" ref={campoBicicleta} …>
        …
      </select>
      …
    </form>
  );
}

El ?. no es decorativo: si el componente se renderiza con el formulario oculto, current seguirá siendo null y el acceso directo lanzaría un error.

Desplazar la lista hasta la bicicleta seleccionada

// src/componentes/ListaBicicletas.jsx (fragmento)
function ListaBicicletas({ bicicletas = [], estaciones = [], idSeleccionada, alSeleccionar, alReservar }) {
  const tarjetaSeleccionada = useRef(null);

  useEffect(() => {
    tarjetaSeleccionada.current?.scrollIntoView({ behavior: 'smooth', block: 'center' });
  }, [idSeleccionada]);

  return (
    <ul className={estilos.lista}>
      {bicicletas.map((bicicleta) => (
        <li
          key={bicicleta.id}
          ref={bicicleta.id === idSeleccionada ? tarjetaSeleccionada : null}
        >
          <TarjetaBicicleta … />
        </li>
      ))}
    </ul>
  );
}

El truco es pasar la referencia solo al elemento que interesa y null a los demás. React asigna current únicamente en ese, y el efecto se dispara cuando cambia idSeleccionada.

Medir un elemento

function ResumenFlota({ flota }) {
  const contenedor = useRef(null);
  const [caben, setCaben] = useState(0);

  useEffect(() => {
    const ancho = contenedor.current.getBoundingClientRect().width;
    setCaben(Math.floor(ancho / 260));   // 260 px por tarjeta
  }, []);

  return <div ref={contenedor}>…</div>;
}

Medir es una operación que solo se puede hacer sobre el DOM real: React no conoce los píxeles. Por eso vive en un efecto, después del commit.

Controlar un <dialog> nativo

Modal (04-02) usaba un <div> con role="dialog". El elemento nativo <dialog> ofrece gratis el foco atrapado y el cierre con Escape, pero se abre y se cierra con métodos imperativos, no con atributos:

// src/componentes/Modal.jsx (variante con <dialog> nativo)
import { useRef, useEffect } from 'react';
import estilos from './Modal.module.css';

/**
 * Props:
 *  - titulo   (cadena, obligatorio)
 *  - abierto  (booleano, obligatorio)
 *  - children (contenido)
 *  - alCerrar (función, obligatorio)
 */
function Modal({ titulo, abierto, children, alCerrar }) {
  const dialogo = useRef(null);

  useEffect(() => {
    const nodo = dialogo.current;
    if (!nodo) return;
    if (abierto && !nodo.open) {
      nodo.showModal();      // método imperativo: no hay atributo equivalente
    } else if (!abierto && nodo.open) {
      nodo.close();
    }
  }, [abierto]);

  return (
    <dialog ref={dialogo} className={estilos.ventana} onClose={alCerrar}>
      <h2>{titulo}</h2>
      {children}
      <button type="button" onClick={alCerrar}>Cerrar</button>
    </dialog>
  );
}

export default Modal;

Este ejemplo resume la filosofía: React declara qué hay en pantalla; cuando el navegador solo ofrece una API imperativa, useRef es el puente.

  1. Cuándo está disponible ref.current

Momento Valor de ref.current
Durante el primer render null (o el valor inicial que pasaste)
Durante cualquier render posterior El nodo del render anterior — no lo uses
En un efecto (useEffect) ✅ El nodo actual, ya en el DOM
En un manejador de eventos ✅ El nodo actual
Después de desmontarse el elemento null (React lo limpia)

La consecuencia práctica: no puedes usar el nodo durante el render. Esto no funciona:

// ❌ current es null en el primer render: TypeError
function PanelMal() {
  const caja = useRef(null);
  const alto = caja.current.offsetHeight;   // 💥
  return <div ref={caja}>…</div>;
}

Y aunque no fallara, sería incorrecto: el render debe ser puro (04-03) y leer el DOM lo hace depender de un estado externo. El sitio para eso es un efecto.

  1. Uso 2: valor mutable de instancia

Además de nodos, current puede guardar cualquier cosa. Tres patrones que aparecen a diario.

Guardar el identificador de un temporizador

Ya lo has visto en CronometroRevision. Es el caso canónico: un dato de fontanería que debe sobrevivir y que nadie mira.

Guardar el valor previo de una prop o de un estado

Útil para animar un cambio, registrar una transición o comparar.

// src/componentes/ContadorPlazas.jsx (fragmento)
import { useState, useRef, useEffect } from 'react';

function ContadorPlazas({ plazasLibres, estacion }) {
  const plazasPrevias = useRef(plazasLibres);
  const [tendencia, setTendencia] = useState('estable');

  useEffect(() => {
    if (plazasLibres > plazasPrevias.current) setTendencia('subiendo');
    else if (plazasLibres < plazasPrevias.current) setTendencia('bajando');
    else setTendencia('estable');

    plazasPrevias.current = plazasLibres;   // se escribe DENTRO del efecto
  }, [plazasLibres]);

  return (
    <p>
      {estacion.nombre}: {plazasLibres} plazas ({tendencia})
    </p>
  );
}

Lo importante es dónde se escribe current: dentro del efecto, es decir, después del render. Si se escribiera en el cuerpo del componente, el valor «previo» ya se habría machacado antes de poder compararlo.

Contar renders en depuración

function TarjetaBicicleta({ bicicleta, nombreEstacion, alSeleccionar, alReservar }) {
  const renders = useRef(0);

  useEffect(() => {
    renders.current += 1;
    console.log(`TarjetaBicicleta ${bicicleta.id}: render nº ${renders.current}`);
  });
  …
}

Con useState este contador sería un bucle infinito: incrementarlo provoca un render, que lo incrementa otra vez. Con useRef no pasa nada, porque no dispara ningún render. Es una herramienta habitual para investigar problemas de rendimiento antes de abrir el Profiler (08-05).

  1. La regla de oro: no leer ni escribir current durante el render

Durante el render, un componente no debe leer ni escribir ref.current. Solo en efectos y manejadores de eventos.

Escribir durante el render rompe la pureza: dos llamadas a la misma función con los mismos argumentos dejarían de producir el mismo resultado, y React se reserva el derecho de llamar a los componentes más de una vez (StrictMode) o de abandonar un render a medias.

// ❌ escritura durante el render: impuro, y con StrictMode cuenta el doble
function Mal() {
  const visitas = useRef(0);
  visitas.current += 1;
  return <p>{visitas.current}</p>;
}

Leer durante el render es menos grave pero igual de traicionero: como cambiar current no repinta, lo que pintes con ese valor puede quedarse desactualizado en pantalla sin que nada lo corrija. Si un dato tiene que verse, es estado.

La excepción tolerada es la inicialización perezosa de la referencia, cuando el valor inicial es caro de construir:

function ReproductorMapa() {
  const instancia = useRef(null);
  if (instancia.current === null) {
    instancia.current = crearMapa();   // se ejecuta una sola vez; nunca lee un valor cambiante
  }
  …
}

Se acepta porque la asignación ocurre exactamente una vez y el resultado no depende del render.

  1. Cerrando la deuda de 03-05: ref frente a FormData

En 03-05 quedó abierta la comparación entre las dos formas de leer un campo no controlado. Aquí están las dos, lado a lado, sobre el mismo formulario de incidencias de CicloUrbano.

// Con FormData: lee TODOS los campos a partir de su atributo name
function FormularioIncidencia({ alRegistrar }) {
  function manejarEnvio(evento) {
    evento.preventDefault();
    const datos = new FormData(evento.target);
    alRegistrar({
      bicicletaId: datos.get('bicicletaId'),
      descripcion: datos.get('descripcion'),
      urgente: datos.get('urgente') === 'on'
    });
    evento.target.reset();
  }
  …
}

// Con useRef: una referencia por campo
function FormularioIncidencia({ alRegistrar }) {
  const campoBicicleta = useRef(null);
  const campoDescripcion = useRef(null);
  const campoUrgente = useRef(null);

  function manejarEnvio(evento) {
    evento.preventDefault();
    alRegistrar({
      bicicletaId: campoBicicleta.current.value,
      descripcion: campoDescripcion.current.value,
      urgente: campoUrgente.current.checked
    });
    campoBicicleta.current.focus();   // ← esto FormData no lo puede hacer
  }
  …
}
Criterio FormData useRef
Leer todos los campos al enviar ✅ Una línea, sin declarar nada ❌ Una referencia por campo
Añadir un campo nuevo ✅ Basta con ponerle name ❌ Hay que declarar otra referencia
Leer un único campo suelto Válido Válido
Actuar sobre el nodo: foco, select(), scrollIntoView ❌ Imposible ✅ Es su terreno
Integrar una biblioteca que necesita el nodo ❌ Imposible ✅ Es su terreno
Valores de tipo fichero Válido (datos.get('foto')) Válido (campo.current.files)

Conclusión: FormData para leer, useRef para actuar. Y son perfectamente combinables: leer con FormData en el submit y mantener una sola referencia al primer campo para devolverle el foco tras enviar es el reparto más limpio.

  1. Pasar referencias a componentes propios en React 19

Poner ref en un elemento del DOM funciona siempre. Ponerlo en un componente propio es otra historia, porque <TarjetaBicicleta /> no es un nodo: es una función.

En React 19 esto ya se resuelve solo: ref es una prop normal de los componentes de función. Basta con recibirla y reenviarla:

// src/componentes/CampoTexto.jsx  (React 19)
function CampoTexto({ etiqueta, id, ref, ...resto }) {
  return (
    <p>
      <label htmlFor={id}>{etiqueta}</label>
      <input id={id} ref={ref} {...resto} />
    </p>
  );
}

export default CampoTexto;
// Uso desde FormularioReserva
const campoFecha = useRef(null);

<CampoTexto ref={campoFecha} id="fechaInicio" etiqueta="Fecha de inicio" type="datetime-local" />
// campoFecha.current es el <input> de dentro

En React 18 y anteriores ref no llegaba como prop: React la interceptaba. Había que envolver el componente en forwardRef, y lo verás continuamente en código existente y en bibliotecas:

// Forma anterior, todavía funcional en React 19 (obsoleta pero soportada)
import { forwardRef } from 'react';

const CampoTexto = forwardRef(function CampoTexto({ etiqueta, id, ...resto }, ref) {
  return (
    <p>
      <label htmlFor={id}>{etiqueta}</label>
      <input id={id} ref={ref} {...resto} />
    </p>
  );
});
React 18 React 19
Recibir ref en un componente de función forwardRef(fn) ref llega como prop normal
Código antiguo con forwardRef Obligatorio Sigue funcionando, marcado como obsoleto

Y una pieza más, mencionada para que la reconozcas: useImperativeHandle permite que un componente exponga a su padre un objeto propio en lugar del nodo del DOM (por ejemplo { enfocar, limpiar }), limitando lo que el padre puede tocar. Es una herramienta de bibliotecas de componentes, poco frecuente en código de aplicación.

  1. Referencias de callback para elementos dinámicos

Cuando el número de elementos no se conoce de antemano —una referencia por cada tarjeta de bicicleta— no puedes declarar un useRef por elemento: violaría la regla 1 de los hooks (04-04), que prohíbe llamarlos dentro de bucles.

La solución es pasar una función en el atributo ref. React la llama con el nodo al montarlo:

// src/componentes/ListaBicicletas.jsx (fragmento con referencias por elemento)
import { useRef } from 'react';

function ListaBicicletas({ bicicletas = [], idSeleccionada, ...resto }) {
  const nodosPorId = useRef(new Map());   // UNA sola referencia, con un mapa dentro

  function desplazarHasta(id) {
    nodosPorId.current.get(id)?.scrollIntoView({ behavior: 'smooth', block: 'center' });
  }

  return (
    <ul>
      {bicicletas.map((bicicleta) => (
        <li
          key={bicicleta.id}
          ref={(nodo) => {
            nodosPorId.current.set(bicicleta.id, nodo);
            // React 19: la función puede devolver una LIMPIEZA
            return () => nodosPorId.current.delete(bicicleta.id);
          }}
        >
          <TarjetaBicicleta bicicleta={bicicleta} {...resto} />
        </li>
      ))}
    </ul>
  );
}

Dos cosas que cambian en React 19:

  • La función de ref puede devolver una función de limpieza, igual que un efecto. React la ejecuta al desmontar el elemento. Antes había que apañarlo comprobando si el argumento era null.
  • Precisamente por eso, en React 19 la función de ref ya no debe devolver otra cosa. Cuidado con la forma abreviada ref={(nodo) => mapa.set(id, nodo)}, que devuelve el mapa sin querer: hay que usar llaves.
// ❌ devuelve el Map: React lo interpretaría como función de limpieza
ref={(nodo) => nodosPorId.current.set(bicicleta.id, nodo)}

// ✅ con llaves: no devuelve nada, o devuelve una limpieza explícita
ref={(nodo) => { nodosPorId.current.set(bicicleta.id, nodo); }}

  1. Cuándo NO usar useRef

useRef es tentador porque «funciona» y no repinta. Estos son los usos que hay que rechazar:

  • Guardar datos que se muestran en pantalla. El síntoma es inconfundible: cambias current, la pantalla no se entera, y acabas añadiendo un useState falso (const [, forzar] = useState(0)) para forzar el repintado. Si tienes que forzar un render, ese dato era estado desde el principio.
  • Sustituir al estado «por rendimiento». El coste de un render bien planteado es despreciable; el coste de una interfaz que muestra datos viejos, no. La optimización tiene su propio módulo (Módulo 8) y sus propias herramientas.
  • Manipular el DOM que React controla. Cambiar nodo.textContent, añadir clases con classList o borrar hijos a mano entra en conflicto con la reconciliación (01-05): React puede sobrescribir tus cambios en el siguiente render, o fallar al no encontrar lo que esperaba. Se puede tocar lo que React no gestiona —foco, scroll, selección, reproducción de un vídeo— pero no la estructura ni el contenido.
  • Como caché de cálculos. Para eso está useMemo (08-03), que además invalida correctamente cuando cambian las entradas.
// ❌ Antipatrón: estado disfrazado de referencia
const filtro = useRef('todos');
function manejarCambioTipo(tipo) {
  filtro.current = tipo;   // la lista NO se actualiza; no hay render
}

// ✅ Se ve en pantalla → es estado
const [tipoElegido, setTipoElegido] = useState('todos');

Como apunte final relacionado, React ofrece useId para generar identificadores únicos y estables con los que asociar label y campos sin colisiones cuando un formulario se repite en la misma página. No tiene que ver con el DOM directo, pero completa el kit de accesibilidad de 03-06:

const idFecha = useId();
<label htmlFor={idFecha}>Fecha de inicio</label>
<input id={idFecha} type="datetime-local" />

Errores Comunes y Consejos

  • Acceder a ref.current durante el render. En el primer render vale null y el acceso lanza TypeError. Usa efectos o manejadores, y ?. como red de seguridad.
  • Olvidar que React vacía current al desmontar. Un temporizador que use la referencia después puede encontrarse un null.
  • Escribir current en el cuerpo del componente. Rompe la pureza y con StrictMode los contadores salen dobles.
  • Usar useRef para datos visibles. Si acabas necesitando forzar un render, ese dato era estado.
  • Poner la referencia en las dependencias de un efecto. El objeto es estable: no aporta nada y confunde a quien lee.
  • Devolver un valor sin querer en una ref de callback. En React 19 se interpreta como función de limpieza. Usa llaves.
  • Modificar el DOM que React gestiona. Toca solo lo que React no controla: foco, scroll, selección, media.
  • Usar forwardRef en código nuevo con React 19. Ya no hace falta; resérvalo para mantener código existente.
  • Consejo: al declarar una referencia, escribe en el nombre lo que guarda (campoBicicleta, identificadorIntervalo, nodosPorId). Una referencia llamada ref no dice nada al que la lee.
  • Consejo: si dudas entre useState y useRef, pregúntate si al cambiar ese valor la pantalla debería verse distinta. Si sí, estado; si no, referencia.

Ejercicios

Ejercicio 1. Este componente pretende contar cuántas veces ha cambiado la bicicleta seleccionada y mostrarlo en pantalla, pero no funciona. Explica por qué y corrígelo.

function ContadorSelecciones({ bicicletaSeleccionada }) {
  const cambios = useRef(0);

  useEffect(() => {
    cambios.current += 1;
  }, [bicicletaSeleccionada]);

  return <p>Has cambiado de bicicleta {cambios.current} veces</p>;
}

Ejercicio 2. Escribe BuscadorBicicletas de forma que, además de su comportamiento actual, ofrezca un botón «Limpiar» que vacíe el campo y devuelva el foco al campo de búsqueda. Explica por qué el foco necesita una referencia y el texto no.

Ejercicio 3. PanelReserva debe abrirse en un <dialog> nativo y cerrarse tanto con el botón «Cancelar» como con la tecla Escape (que el navegador maneja solo). Escribe el componente DialogoReserva que envuelve a PanelReserva, con props abierto, alCerrar y children, asegurándote de que el estado de React y el estado del <dialog> no se desincronizan.

Soluciones

Solución 1.

El fallo es de categoría: cambios se muestra en pantalla, así que debería ser estado. Al escribir en cambios.current no se produce ningún render, de modo que el <p> sigue mostrando el valor con el que se pintó por última vez. El contador sube por dentro y la pantalla se queda en 0 (o en el número que hubiera cuando otro cambio provocó un repintado por casualidad, que es peor: el error se vuelve intermitente).

function ContadorSelecciones({ bicicletaSeleccionada }) {
  const [cambios, setCambios] = useState(0);
  const esPrimerRender = useRef(true);   // esto SÍ es una referencia legítima

  useEffect(() => {
    if (esPrimerRender.current) {
      esPrimerRender.current = false;    // el montaje inicial no cuenta como cambio
      return;
    }
    setCambios((previos) => previos + 1);
  }, [bicicletaSeleccionada]);

  return <p>Has cambiado de bicicleta {cambios} veces</p>;
}

La solución enseña las dos caras: cambios pasa a useState porque se ve; esPrimerRender se queda como useRef porque es fontanería interna que no debe repintar nada. Y setCambios usa la forma funcional (05-01) porque el nuevo valor depende del anterior.

Solución 2.

// src/componentes/BuscadorBicicletas.jsx
import { useState, useRef } from 'react';

/**
 * Props:
 *  - alBuscar (función, obligatoria)
 */
function BuscadorBicicletas({ alBuscar }) {
  const [texto, setTexto] = useState('');
  const campo = useRef(null);

  function manejarCambio(evento) {
    setTexto(evento.target.value);
    alBuscar(evento.target.value);
  }

  function manejarLimpiar() {
    setTexto('');
    alBuscar('');
    campo.current?.focus();   // acción sobre el nodo: solo posible con la referencia
  }

  return (
    <div>
      <input
        ref={campo}
        type="search"
        value={texto}
        onChange={manejarCambio}
        aria-label="Buscar bicicletas por modelo"
      />
      <button type="button" onClick={manejarLimpiar} disabled={texto === ''}>
        Limpiar
      </button>
    </div>
  );
}

export default BuscadorBicicletas;

Por qué el texto no necesita referencia y el foco sí: el texto se ve en el campo, así que es estado; React lo pinta con value={texto} y basta con ponerlo a '' para vaciar el campo. El foco, en cambio, no es un dato que React dibuje: es una propiedad del navegador. No existe ningún atributo de JSX que diga «este campo tiene el foco ahora»; solo existe el método focus() del nodo. Por eso hace falta llegar hasta el nodo real. Es la distinción exacta entre lo declarativo y lo imperativo.

Solución 3.

// src/componentes/DialogoReserva.jsx
import { useRef, useEffect } from 'react';
import estilos from './DialogoReserva.module.css';

/**
 * Envoltorio de PanelReserva en un <dialog> nativo.
 * Props:
 *  - abierto  (booleano, obligatorio)
 *  - alCerrar (función, obligatorio)
 *  - children (contenido del diálogo)
 */
function DialogoReserva({ abierto, alCerrar, children }) {
  const dialogo = useRef(null);

  useEffect(() => {
    const nodo = dialogo.current;
    if (!nodo) return;

    if (abierto && !nodo.open) {
      nodo.showModal();
    } else if (!abierto && nodo.open) {
      nodo.close();
    }
  }, [abierto]);

  return (
    <dialog
      ref={dialogo}
      className={estilos.dialogo}
      onClose={alCerrar}          // ← se dispara también con Escape
      onCancel={(evento) => {     // Escape lanza 'cancel' antes de 'close'
        evento.preventDefault();  // evitamos el cierre nativo directo…
        alCerrar();               // …y dejamos que el estado de React lo ordene
      }}
      aria-labelledby="titulo-dialogo-reserva"
    >
      <h2 id="titulo-dialogo-reserva">Confirmar reserva</h2>
      {children}
    </dialog>
  );
}

export default DialogoReserva;

Las claves de la sincronización: las comprobaciones !nodo.open y nodo.open evitan llamar a showModal() sobre un diálogo ya abierto —lo que lanza un error del navegador— y a close() sobre uno cerrado. Y onClose es imprescindible porque el navegador puede cerrar el diálogo por su cuenta con Escape: sin ese aviso, el estado de React seguiría diciendo abierto: true mientras el diálogo ya está cerrado, y el siguiente intento de abrirlo no haría nada. Este es el problema típico al integrar un elemento con estado propio: hay que propagar sus cambios de vuelta a React, igual que un campo controlado propaga los suyos con onChange (03-04).

Conclusión

useRef es una caja { current } que sobrevive a los renders y que, al cambiar, no provoca ninguno. De ahí salen sus dos usos: guardar un valor mutable de instancia —el identificador de un temporizador, el valor previo de una prop, un contador de depuración— y sostener una referencia a un nodo del DOM para hacer lo que React no expresa de forma declarativa: enfocar un campo, medir un elemento, desplazar la lista hasta la bicicleta seleccionada o abrir un <dialog>. Has visto cuándo current está disponible (después del commit, nunca durante el render), la regla de no leerlo ni escribirlo mientras se renderiza, la comparación cerrada con FormData que quedó abierta en 03-05, cómo ref es ya una prop normal en React 19 —con forwardRef reducido a código heredado—, y las referencias de callback con limpieza para elementos que aparecen y desaparecen. Y sobre todo tienes la frontera clara: si el dato se ve, es estado; si no se ve, quizá sea una referencia.

Con useState, useEffect y useRef ya controlas lo que un componente recuerda y cómo se conecta con el exterior. Pero sigue habiendo una deuda pendiente desde 04-01: cuando un dato tiene que llegar desde App hasta un componente enterrado cinco niveles más abajo, hoy solo sabes hacerlo pasando props por cada escalón, aunque los componentes intermedios no las usen para nada. Esa perforación de props ensucia todas las firmas del camino y convierte cualquier cambio en un recorrido a mano por media aplicación. React tiene un canal directo entre un ancestro y cualquiera de sus descendientes, sin escalas. La próxima lección es Hook useContext.

Curso de React

Módulo 1: Introducción a React

Módulo 2: Componentes de React

Módulo 3: Trabajando con Eventos

Módulo 4: Conceptos Avanzados de Componentes

Módulo 5: Hooks de React

Módulo 6: Enrutamiento en React

Módulo 7: Gestión del Estado

Módulo 8: Optimización del Rendimiento

Módulo 9: Pruebas en React

Módulo 10: Temas Avanzados

Módulo 11: Proyecto: Construyendo una Aplicación Completa

© Copyright 2026. Todos los derechos reservados