El módulo anterior terminó montando el mapa de los hooks; ahora empieza el recorrido por el territorio, y el primer punto del mapa es el que ya usas desde 02-04: useState. Allí aprendiste lo esencial —declarar estado, actualizarlo, no mutarlo— y con eso el catálogo de CicloUrbano funciona. Pero useState esconde un comportamiento que sorprende a casi todo el mundo la primera vez que lo encuentra: durante un render, la variable de estado no cambia nunca, por muchas veces que llames al actualizador. Quien no entiende esto acaba escribiendo código que «pierde» actualizaciones sin saber por qué, y desconfiando de React. En esta lección vas a entender el mecanismo real: el estado como instantánea, la cola de actualizaciones, la agrupación de cambios, la inicialización perezosa, un recetario completo de actualización inmutable aplicado a las reservas de CicloUrbano, cinco principios para estructurar bien el estado y el truco de reiniciarlo con la prop key.

Contenido

  1. La firma de useState y el par que devuelve
  2. Por qué el actualizador es estable entre renders
  3. El estado es una instantánea del render
  4. Tres setContador(contador + 1) que suman uno solo
  5. La forma funcional y la cola de actualizaciones
  6. Agrupación de actualizaciones (batching)
  7. Inicialización perezosa y la trampa del cálculo directo
  8. Objetos y arrays: recetario de actualización inmutable
  9. Estructurar bien el estado: cinco principios
  10. Reiniciar el estado de un componente con la prop key
  11. Valor directo o función actualizadora: tabla de decisión

  1. La firma de useState y el par que devuelve

const [valor, establecerValor] = useState(valorInicial);

useState recibe un argumento —el valor inicial— y siempre devuelve un array de exactamente dos posiciones:

Posición Qué es Cómo se usa
[0] El valor del estado en este render Se lee. Nunca se asigna
[1] La función actualizadora Se llama para pedir un cambio y un nuevo render

Que devuelva un array y no un objeto no es un capricho: al desestructurar un array eliges tú los nombres, posición a posición. Si devolviera { valor, establecerValor } tendrías que renombrar en cada llamada, y con tres estados en el mismo componente el código sería ilegible.

// Con array (lo que hace React): nombres libres, una línea
const [tipoElegido, setTipoElegido] = useState('todos');
const [horasReserva, setHorasReserva] = useState(2);

// Si devolviera un objeto: renombrado obligatorio en cada uso
const { valor: tipoElegido, establecer: setTipoElegido } = useState('todos');

La convención universal en el ecosistema es [algo, setAlgo]. En este curso la respetamos para el actualizador aunque el resto de nombres esté en español, porque set forma parte del idioma de React tanto como useState.

El argumento solo se usa en el primer render. A partir de ahí React ignora lo que le pases: el valor inicial es exactamente eso, inicial. Esta línea no «reinicia» nada en el render número 40:

const [horasReserva, setHorasReserva] = useState(2);   // ¿siempre 2? No: 2 solo la primera vez

  1. Por qué el actualizador es estable entre renders

Esta es una propiedad que se enuncia poco y que resulta muy útil:

React garantiza que la función actualizadora que devuelve useState es la misma referencia en todos los renders del componente. No se recrea nunca.

Compruébalo con una comparación de identidad:

let actualizadorAnterior = null;

function ContadorPlazas({ estacion }) {
  const [ocupadas, setOcupadas] = useState(0);

  console.log('¿Mismo actualizador que el render anterior?', setOcupadas === actualizadorAnterior);
  actualizadorAnterior = setOcupadas;
  // render 1: false (no había anterior) · render 2, 3, 4...: true, siempre

Tres consecuencias prácticas que aprovecharás en las próximas lecciones:

  • No hace falta incluirlo en las dependencias de un efecto (05-02). El linter lo sabe y no te lo exige.
  • No hay que envolverlo en useCallback (08-03) para pasarlo a un hijo memorizado: ya es estable por definición.
  • Se puede pasar directamente como prop de callback sin miedo a provocar renders innecesarios.
// Correcto y suficiente: setOcupadas no cambia nunca
useEffect(() => {
  const id = setInterval(() => setOcupadas((previas) => previas + 1), 5000);
  return () => clearInterval(id);
}, []);   // sin setOcupadas en las dependencias: React garantiza que es estable

La misma garantía se aplica a la función despachar de useReducer, como verás en 05-05.

  1. El estado es una instantánea del render

Aquí está la idea central de la lección, y conviene enunciarla despacio.

Cada render es una fotografía congelada. Las variables de estado de ese render son constantes: valen lo que valían cuando React llamó a la función del componente, y no cambian hasta que empieza el render siguiente.

Recuerda cómo funciona un componente de función: es una función que React llama. En cada render, React la ejecuta de nuevo y useState devuelve el valor que corresponde a ese render. La variable horasReserva no es una caja mágica conectada a React: es una const local de esa ejecución concreta.

function PanelReserva({ bicicleta, horas, alCambiarHoras }) {
  // 'horas' es una constante DE ESTE RENDER. Nada la va a cambiar durante esta ejecución.
  console.log('Render con horas =', horas);
  ...
}

El ejemplo que lo hace evidente es un manejador con un retardo:

function SelectorHoras() {
  const [horas, setHoras] = useState(2);

  function manejarReservaDiferida() {
    setHoras(horas + 3);                       // pedimos pasar a 5
    setTimeout(() => {
      alert(`Reservando ${horas} horas`);      // ¿qué muestra: 2 o 5?
    }, 3000);
  }

  return (
    <>
      <p>Horas: {horas}</p>
      <button type="button" onClick={manejarReservaDiferida}>Reservar en 3 s</button>
    </>
  );
}

Muestra 2. Y no es un error: es exactamente lo correcto. El manejador manejarReservaDiferida fue creado durante el render en el que horas valía 2, y esa función captura ese valor. Aunque tres segundos después la pantalla ya muestre 5, la función sigue viviendo en la fotografía del render antiguo. Es el comportamiento normal de los cierres (closures) de JavaScript, aplicado a React.

Piénsalo así: si pulsas «enviar» en un formulario y luego, mientras se envía, cambias un campo, el mensaje enviado debe ser el que había al pulsar, no el que hay ahora. La instantánea no es un obstáculo: es lo que hace que los eventos sean predecibles.

  1. Tres setContador(contador + 1) que suman uno solo

Este es el ejemplo clásico, y ahora ya tienes las herramientas para explicarlo. Añadimos a CicloUrbano un botón que suma tres plazas de golpe:

// src/componentes/ContadorPlazas.jsx (versión con el fallo)
import { useState } from 'react';

function ContadorPlazas() {
  const [plazasLibres, setPlazasLibres] = useState(0);

  function manejarLiberarTres() {
    setPlazasLibres(plazasLibres + 1);
    setPlazasLibres(plazasLibres + 1);
    setPlazasLibres(plazasLibres + 1);
  }

  return (
    <>
      <p>Plazas libres: {plazasLibres}</p>
      <button type="button" onClick={manejarLiberarTres}>Liberar 3 plazas</button>
    </>
  );
}

Pulsas una vez y el contador marca 1, no 3. Sustituye mentalmente la variable por su valor en ese render y verás por qué:

// Durante ese render, plazasLibres vale 0. Las tres líneas son, literalmente:
setPlazasLibres(0 + 1);   // encola: "el próximo estado es 1"
setPlazasLibres(0 + 1);   // encola: "el próximo estado es 1"
setPlazasLibres(0 + 1);   // encola: "el próximo estado es 1"

Las tres llamadas encolan la misma orden: «pon el estado a 1». React procesa la cola, obtiene 1, y renderiza una vez. No se ha perdido ninguna actualización: es que las tres decían lo mismo.

La conclusión que hay que interiorizar: llamar al actualizador no cambia la variable en curso. setPlazasLibres(1) no hace plazasLibres = 1; hace «apunta que en el próximo render valdrá 1».

  1. La forma funcional y la cola de actualizaciones

La solución es pasar una función actualizadora en lugar de un valor. React la guarda en la cola y la ejecuta después, una detrás de otra, pasándole el resultado de la anterior.

function manejarLiberarTres() {
  setPlazasLibres((previas) => previas + 1);
  setPlazasLibres((previas) => previas + 1);
  setPlazasLibres((previas) => previas + 1);
}

Ahora sí: 3. Así procesa React la cola antes del siguiente render:

flowchart LR
    A["Estado actual: 0"] --> B["f1: previas => previas + 1<br/>0 → 1"]
    B --> C["f2: previas => previas + 1<br/>1 → 2"]
    C --> D["f3: previas => previas + 1<br/>2 → 3"]
    D --> E["Estado del próximo render: 3"]
    style A fill:#e0f2fe
    style E fill:#dcfce7

Y este es el contraste con la versión de valor directo, donde cada elemento de la cola es un valor fijo que sustituye lo que hubiera:

Cola encolada Cómo la procesa React Estado final
1, 1, 1 Sustituir, sustituir, sustituir 1
f, f, f con f = p => p + 1 f(0)=1, f(1)=2, f(2)=3 3
5, f, f Sustituir a 5, f(5)=6, f(6)=7 7

La tercera fila enseña que se pueden mezclar: un valor directo descarta lo anterior y reinicia el cálculo. Es útil, por ejemplo, para «reiniciar y sumar»:

setPlazasLibres(0);                              // reinicia
setPlazasLibres((previas) => previas + estacion.plazas);   // suma sobre el 0 recién puesto

Reglas para escribir buenas funciones actualizadoras:

  • Deben ser puras: calcular y devolver el nuevo estado, sin console.log que dependan del orden, sin peticiones, sin mutar nada. React puede llamarlas dos veces en desarrollo con StrictMode (01-03) precisamente para detectar impurezas.
  • Nombra el parámetro con claridad: previas, reservasPrevias, datosPrevios. Evita la letra suelta.
  • No leas otras variables de estado dentro: si el cálculo depende de dos estados a la vez, es señal de que esos dos estados deberían ser uno solo, o de que ha llegado el momento de useReducer (05-05).

  1. Agrupación de actualizaciones (batching)

React no repinta después de cada llamada al actualizador: agrupa todas las actualizaciones que se producen y hace un único render al final. Es lo que se llama batching.

function manejarConfirmacion({ bicicleta, horas }) {
  setBicicletaSeleccionada(null);       // 1
  setHorasReserva(2);                   // 2
  setAvisoVisible(true);                // 3
  // React NO ha renderizado todavía. Renderiza UNA vez cuando termina este manejador.
}

Tres cambios de estado, un solo render. Sin agrupación tendrías tres renders y estados intermedios visibles en pantalla: la interfaz mostraría por un instante el panel vacío con las horas antiguas. La agrupación evita esos estados a medias.

El cambio importante de React 18 en adelante: la agrupación es automática en todas partes. Antes solo agrupaba dentro de manejadores de eventos de React; el código dentro de promesas, temporizadores o manejadores nativos provocaba un render por llamada.

Contexto React 17 React 18 y 19
Manejador onClick de React Agrupa (1 render) Agrupa (1 render)
Dentro de setTimeout No agrupa (3 renders) Agrupa (1 render)
Dentro de .then() de una promesa No agrupa (3 renders) Agrupa (1 render)
Dentro de un addEventListener nativo No agrupa (3 renders) Agrupa (1 render)
// En React 19 esto provoca UN solo render, no tres
async function manejarCargaEstaciones() {
  const respuesta = await fetch('/api/estaciones');
  const datos = await respuesta.json();

  setEstaciones(datos);        // ┐
  setCargando(false);          // ├─ un único render al final
  setError(null);              // ┘
}

Si alguna vez necesitas forzar un repintado inmediato antes de continuar —un caso muy raro, típicamente para medir el DOM—, existe flushSync de react-dom. Considéralo una válvula de escape: su uso habitual es un síntoma de que algo está mal planteado.

  1. Inicialización perezosa y la trampa del cálculo directo

El argumento de useState solo se usa en el primer render, pero se evalúa en todos. Esa diferencia tiene consecuencias.

// ❌ MAL: calcularResumenFlota(bicicletas) se EJECUTA en cada render
const [resumen, setResumen] = useState(calcularResumenFlota(bicicletas));

Aunque React descarte el resultado a partir del segundo render, JavaScript ha tenido que ejecutar la función para poder pasar su valor como argumento. Si recorre cinco bicicletas da igual; si lee localStorage, parsea un JSON grande o recorre miles de reservas, estás pagando ese coste en cada repintado.

La solución es pasar la función, sin llamarla. React la ejecutará una única vez, en la inicialización:

// ✅ BIEN: React llama a la función solo en el primer render
const [resumen, setResumen] = useState(() => calcularResumenFlota(bicicletas));

Un caso muy habitual en CicloUrbano: recuperar el borrador de reserva guardado en el navegador.

function FormularioReserva({ alCrearReserva }) {
  const [datos, setDatos] = useState(() => {
    const guardado = localStorage.getItem('ciclourbano:borrador');
    return guardado ? JSON.parse(guardado) : DATOS_INICIALES;
  });
  ...
}

Sin la función envolvente, cada pulsación de tecla en el formulario provocaría un render, y cada render leería y parsearía localStorage para tirar el resultado a la basura.

Forma Cuándo se ejecuta el cálculo Cuándo usarla
useState(valor) Nunca hay cálculo: es un literal Valores simples: 0, '', false, 'todos', null
useState(calcular()) En cada render Nunca, si calcular no es trivial
useState(() => calcular()) Solo en el primer render Cálculos caros, lectura de localStorage, parseo de JSON

Cuidado con la ambigüedad si el estado es una función. Si quieres guardar una función como valor de estado, useState(miFuncion) la interpretaría como inicializador perezoso y guardaría su resultado. Hay que envolverla: useState(() => miFuncion).

  1. Objetos y arrays: recetario de actualización inmutable

En 02-04 quedó establecida la regla: el estado se sustituye, no se modifica. React compara la referencia anterior con la nueva y, si son la misma, considera que no ha cambiado nada y no renderiza. Aquí está el recetario completo, aplicado a la lista de reservas de CicloUrbano.

Partimos de este estado en App:

// src/datos/dominio.js
export const reservas = [
  {
    id: 'res-01',
    bicicletaId: 'bici-002',
    usuario: 'usr-01',
    fechaInicio: '2026-05-04T09:00',
    horas: 2,
    estado: 'activa'
  }
];
// src/App.jsx (fragmento)
const [listaReservas, setListaReservas] = useState(reservas);

Recetario de arrays

Operación ❌ Muta (mal) ✅ Sustituye (bien)
Añadir al final arr.push(x) setArr([...arr, x])
Añadir al principio arr.unshift(x) setArr([x, ...arr])
Eliminar por id arr.splice(i, 1) setArr(arr.filter((e) => e.id !== id))
Sustituir un elemento arr[i] = x setArr(arr.map((e) => (e.id === id ? x : e)))
Ordenar arr.sort(f) setArr([...arr].sort(f))
Invertir arr.reverse() setArr([...arr].reverse())
Insertar en posición arr.splice(i, 0, x) setArr([...arr.slice(0, i), x, ...arr.slice(i)])
Vaciar arr.length = 0 setArr([])

Los cuatro casos que más aparecen, escritos con la forma funcional (porque dependen del valor anterior):

// AÑADIR una reserva nueva
function manejarCrearReserva(nuevaReserva) {
  setListaReservas((previas) => [...previas, nuevaReserva]);
}

// ELIMINAR (cancelar definitivamente) por identificador
function manejarEliminarReserva(idReserva) {
  setListaReservas((previas) => previas.filter((reserva) => reserva.id !== idReserva));
}

// SUSTITUIR una reserva entera
function manejarReemplazarReserva(reservaActualizada) {
  setListaReservas((previas) =>
    previas.map((reserva) => (reserva.id === reservaActualizada.id ? reservaActualizada : reserva))
  );
}

// CAMBIAR UNA PROPIEDAD de un elemento (sin tocar los demás)
function manejarCancelarReserva(idReserva) {
  setListaReservas((previas) =>
    previas.map((reserva) =>
      reserva.id === idReserva ? { ...reserva, estado: 'cancelada' } : reserva
    )
  );
}

Fíjate en manejarCancelarReserva: el map devuelve el mismo objeto para las reservas que no cambian y un objeto nuevo solo para la afectada. Eso es exactamente lo que queremos: la mínima cantidad de referencias nuevas, para que las optimizaciones del Módulo 8 puedan saltarse el resto.

Aviso sobre sort y reverse: son métodos que modifican el array original. [...arr].sort(f) copia primero. JavaScript moderno ofrece además toSorted() y toReversed(), que ya devuelven una copia y hacen el [...arr] innecesario.

Recetario de objetos y anidamiento

const [borrador, setBorrador] = useState({
  bicicletaId: '',
  fechaInicio: '',
  horas: 2,
  condiciones: false,
  contacto: { email: '', telefono: '' }   // ← nivel anidado
});
Operación ✅ Cómo se hace
Cambiar una propiedad setBorrador({ ...borrador, horas: 4 })
Cambiar una propiedad dinámica setBorrador({ ...borrador, [campo]: valor })
Cambiar una propiedad anidada setBorrador({ ...borrador, contacto: { ...borrador.contacto, email } })
Eliminar una propiedad const { horas, ...resto } = borrador; setBorrador(resto);

El caso anidado es el que más errores produce, porque hay que copiar todos los niveles del camino hasta la propiedad que cambia:

function manejarCambioEmail(email) {
  setBorrador((previo) => ({
    ...previo,                            // copia el nivel 1
    contacto: { ...previo.contacto, email }   // copia el nivel 2 y cambia email
  }));
}

Esto olvida copiar el nivel intermedio y borra el teléfono:

// ❌ contacto pasa a ser { email } y el teléfono desaparece
setBorrador((previo) => ({ ...previo, contacto: { email } }));

Y este otro parece funcionar pero es una mutación disfrazada: modifica el objeto que ya estaba en el estado, así que la referencia de borrador no cambia y React puede no renderizar:

// ❌ mutación: previo.contacto es el MISMO objeto que hay en el estado
setBorrador((previo) => {
  previo.contacto.email = email;
  return { ...previo };
});

Ten en cuenta también que las paréntesis alrededor del objeto en (previo) => ({ ... }) son obligatorios: sin ellos, JavaScript interpreta las llaves como el cuerpo de la función y la flecha devuelve undefined.

Si el anidamiento pasa de dos niveles, la respuesta correcta casi nunca es escribir spreads más largos: es aplanar el estado (apartado siguiente) o recurrir a una biblioteca de inmutabilidad como Immer, que permite escribir código de aspecto mutable y produce copias por debajo.

  1. Estructurar bien el estado: cinco principios

Elegir qué guardar en el estado importa más que saber actualizarlo. Estos cinco principios evitan la mayoría de los errores de estado que verás en producción.

  1. Agrupa lo que cambia junto

Si dos variables se actualizan siempre a la vez, son una sola.

// ❌ dos estados que nunca cambian por separado
const [x, setX] = useState(0);
const [y, setY] = useState(0);

// ✅ uno solo
const [posicion, setPosicion] = useState({ x: 0, y: 0 });

  1. No dupliques el mismo dato

Es la regla de la fuente única de verdad de 04-01, ahora dentro de un mismo componente.

// ❌ la bicicleta está dos veces: si cambia la lista, la copia queda obsoleta
const [bicicletas, setBicicletas] = useState(datosIniciales);
const [bicicletaSeleccionada, setBicicletaSeleccionada] = useState(null);

// ✅ guarda el identificador y busca el objeto al renderizar
const [idSeleccionada, setIdSeleccionada] = useState(null);
const bicicletaSeleccionada = bicicletas.find((b) => b.id === idSeleccionada) ?? null;

La versión correcta tiene un beneficio inmediato: si el operario cambia el estado de bici-002 a «mantenimiento», la tarjeta seleccionada se entera. Con la copia, seguiría mostrando los datos viejos.

  1. Evita el estado contradictorio

Tres booleanos sueltos permiten combinaciones que no deberían existir.

// ❌ ¿qué significa cargando=true y error='Fallo'? Es un estado imposible
const [cargando, setCargando] = useState(false);
const [error, setError] = useState(null);
const [datos, setDatos] = useState(null);

Con esa estructura hay 2 × 2 × 2 = 8 combinaciones y solo 4 tienen sentido. Basta con olvidar un setCargando(false) en una rama de error para que la interfaz muestre a la vez el mensaje de fallo y el indicador de carga.

// ✅ una sola variable con los estados que EXISTEN de verdad
const [estadoCarga, setEstadoCarga] = useState('inactivo');
// 'inactivo' | 'cargando' | 'exito' | 'error'
const [datos, setDatos] = useState(null);
const [error, setError] = useState(null);

const estaCargando = estadoCarga === 'cargando';   // derivado, no estado

  1. Evita el estado redundante que puedes derivar

Si un dato se puede calcular a partir de otros, no es estado. Ya lo viste en 04-01 con bicicletasVisibles; el error más común es el «total» calculado:

// ❌ redundante: hay que acordarse de recalcularlo en cada cambio
const [horas, setHoras] = useState(2);
const [total, setTotal] = useState(5);

// ✅ derivado: siempre correcto, imposible desincronizar
const [horas, setHoras] = useState(2);
const total = horas * bicicleta.precioHora;

  1. Aplana lo anidado

Guardar la jerarquía tal cual llega del servidor obliga a spreads de tres niveles para cambiar un dato. Guardar índices por identificador convierte esa operación en una línea.

// ❌ profundamente anidado: cambiar una bici obliga a copiar estación y lista
const [red, setRed] = useState({
  estaciones: [{ id: 'est-01', nombre: 'Plaza Mayor', bicicletas: [{ id: 'bici-001', ... }] }]
});

// ✅ aplanado por identificador
const [estacionesPorId, setEstacionesPorId] = useState({
  'est-01': { id: 'est-01', nombre: 'Plaza Mayor', barrio: 'Centro', plazas: 20, bicicletaIds: ['bici-001', 'bici-002'] }
});
const [bicicletasPorId, setBicicletasPorId] = useState({
  'bici-001': { id: 'bici-001', modelo: 'Urbana Clásica', tipo: 'urbana', estado: 'disponible', estacionId: 'est-01', precioHora: 2.5 }
});

// Cambiar el estado de una bicicleta: una línea
setBicicletasPorId((previas) => ({
  ...previas,
  'bici-001': { ...previas['bici-001'], estado: 'mantenimiento' }
}));

Cuando estos cinco principios se quedan cortos —muchas piezas relacionadas, transiciones con reglas, manejadores que tocan tres estados a la vez— la respuesta ya no es reorganizar useState, sino cambiar de herramienta: useReducer reúne todas esas piezas bajo una función de transición única. Es la lección 05-05.

  1. Reiniciar el estado de un componente con la prop key

React conserva el estado de un componente mientras se renderice en la misma posición del árbol con el mismo tipo. Esto produce un comportamiento que sorprende: si cambias de bicicleta seleccionada, el PanelReserva mantiene las horas que había puestas para la anterior.

<PanelReserva bicicleta={bicicletaSeleccionada} />
// Cambia de bici-001 a bici-005 → mismo componente, misma posición → el estado interno sobrevive

Podrías «arreglarlo» con un efecto que reinicie el estado al cambiar la prop, pero es la solución peor y la veremos como antipatrón en 05-02. La solución idiomática es una key distinta:

<PanelReserva
  key={bicicletaSeleccionada?.id}
  bicicleta={bicicletaSeleccionada}
  horas={horasReserva}
  alCambiarHoras={manejarCambioHoras}
  alConfirmar={manejarConfirmacion}
/>

Al cambiar la key, React considera que es otro componente distinto: desmonta el anterior con todo su estado y monta uno nuevo desde cero. Es la misma key que usas en las listas (03-03), aplicada aquí con otro propósito: no identificar hermanos, sino declarar identidad a lo largo del tiempo.

flowchart TD
    A["bicicletaSeleccionada = bici-001"] --> B["PanelReserva key='bici-001'<br/>horas: 4"]
    B --> C{"El usuario elige bici-005"}
    C --> D["React ve una key distinta"]
    D --> E["Desmonta el panel anterior<br/>(su estado se pierde)"]
    E --> F["Monta PanelReserva key='bici-005'<br/>horas: 1 (valor inicial)"]
    style E fill:#fecaca
    style F fill:#dcfce7

Casos típicos donde la key es la herramienta correcta:

  • Un formulario que debe vaciarse al cambiar de elemento editado.
  • Un panel de detalle que debe volver a su pestaña inicial al cambiar de registro.
  • Un componente de terceros que no ofrece forma de reiniciarse.

  1. Valor directo o función actualizadora: tabla de decisión

Situación Forma recomendada Ejemplo
El nuevo valor no depende del anterior Valor directo setTipoElegido('electrica')
El nuevo valor sí depende del anterior Función actualizadora setHoras((previas) => previas + 1)
Varias actualizaciones seguidas del mismo estado Función actualizadora, siempre setPlazas((p) => p + 1) × 3
Dentro de un setTimeout, fetch o await Función actualizadora setContador((p) => p + 1) tras 5 s
Dentro de un efecto con dependencias reducidas Función actualizadora evita añadir el estado a [deps] (05-02)
Añadir, quitar o modificar en un array/objeto Función actualizadora setReservas((previas) => [...previas, nueva])
Poner un valor fijo o reiniciar Valor directo setBicicletaSeleccionada(null)

Regla práctica para no dudar: usa la forma funcional siempre que el nuevo estado se calcule a partir del actual. No penaliza el rendimiento, no complica el código y elimina de raíz toda una familia de errores difíciles de reproducir.

Errores Comunes y Consejos

  • Leer el estado justo después de actualizarlo. setHoras(5); console.log(horas); imprime el valor viejo. Es la instantánea del apartado 3, no un retraso. Si necesitas el valor nuevo, calcúlalo en una constante: const nuevasHoras = 5; setHoras(nuevasHoras); console.log(nuevasHoras);.
  • Llamar al actualizador durante el render. setContador(1) en el cuerpo del componente provoca un bucle infinito («Too many re-renders»). Los actualizadores se llaman desde manejadores o desde efectos, nunca durante el render. La única excepción, poco frecuente, es el ajuste condicional de estado con un if que rompe el bucle.
  • useState(calcular()) en lugar de useState(() => calcular()). El cálculo se ejecuta en todos los renders. Revisa esta línea siempre que el inicializador no sea un literal.
  • Mutar el estado y renderizar «a mano». reservas.push(nueva); setReservas(reservas); no repinta: la referencia es la misma. Y si además hay memorización (Módulo 8), el bug se vuelve intermitente.
  • Estados booleanos que se contradicen. cargando, error y exito como tres booleanos independientes acaban mostrando pantallas imposibles. Una cadena con los estados válidos lo resuelve.
  • Guardar en el estado el objeto entero cuando basta el id. Es la duplicación del principio 2: la copia envejece mal.
  • Olvidar los paréntesis en (previo) => ({ ... }). Sin ellos la función no devuelve nada y el estado pasa a undefined.
  • Consejo de depuración: cuando un estado no se actualiza como esperas, escribe la línea sustituyendo mentalmente cada variable por su valor en ese render. La mayoría de los misterios se resuelven ahí.
  • Consejo de diseño: antes de añadir un useState, pregúntate «¿puedo calcularlo?». La mayor parte del estado que sobra es estado derivado que alguien no quiso derivar.

Ejercicios

Ejercicio 1. Este componente de CicloUrbano debe sumar las horas de golpe según el botón que se pulse, pero al pulsar «+3 horas» solo suma una. Corrígelo y explica por qué fallaba.

import { useState } from 'react';

function AjustadorHoras() {
  const [horas, setHoras] = useState(1);

  function manejarSumarTres() {
    setHoras(horas + 1);
    setHoras(horas + 1);
    setHoras(horas + 1);
  }

  function manejarReiniciarYSumar() {
    setHoras(0);
    setHoras(horas + 2);
  }

  return (
    <>
      <p>Horas de la reserva: {horas}</p>
      <button type="button" onClick={manejarSumarTres}>+3 horas</button>
      <button type="button" onClick={manejarReiniciarYSumar}>Reiniciar y +2</button>
    </>
  );
}

Ejercicio 2. El panel de reservas de CicloUrbano guarda su estado así. Reestructúralo aplicando los cinco principios del apartado 9 y escribe los manejadores manejarSeleccion, manejarCambioHoras y manejarConfirmar con la estructura nueva.

const [reservas, setReservas] = useState(reservasIniciales);
const [reservaSeleccionada, setReservaSeleccionada] = useState(null);   // objeto completo
const [horas, setHoras] = useState(2);
const [precioTotal, setPrecioTotal] = useState(0);
const [enviando, setEnviando] = useState(false);
const [enviado, setEnviado] = useState(false);
const [hayError, setHayError] = useState(false);
const [mensajeError, setMensajeError] = useState('');

Ejercicio 3. Escribe los tres manejadores que gestionan la lista de reservas de App, todos con actualización inmutable y forma funcional: manejarConfirmar(idReserva) cambia el estado de esa reserva a 'confirmada'; manejarCancelar(idReserva) lo cambia a 'cancelada' y añade la propiedad canceladaEn con la fecha ISO actual; manejarPurgar() elimina de la lista todas las reservas cuyo estado sea 'cancelada'. Parte del estado const [listaReservas, setListaReservas] = useState(reservas);.

Soluciones

Solución 1.

function manejarSumarTres() {
  setHoras((previas) => previas + 1);
  setHoras((previas) => previas + 1);
  setHoras((previas) => previas + 1);
}

function manejarReiniciarYSumar() {
  setHoras(0);                            // valor directo: descarta lo anterior
  setHoras((previas) => previas + 2);     // funcional: opera sobre el 0 recién encolado
}

manejarSumarTres fallaba porque horas es una constante de ese render. Con horas = 1, las tres líneas eran literalmente setHoras(2) tres veces: la cola contenía 2, 2, 2 y el resultado era 2 (una sola hora sumada). Con la forma funcional, la cola contiene tres funciones que React aplica en cadena: 1→2→3→4.

En manejarReiniciarYSumar la mezcla es intencionada. Con la versión original (setHoras(0); setHoras(horas + 2);) y horas = 5, la cola sería 0 y luego 7: el reinicio se pierde y el resultado es 7. Con la versión corregida la cola es 0 y luego f, así que f(0) = 2, que es lo que promete el botón.

Solución 2.

// Ocho useState → tres, sin estados imposibles ni datos duplicados
const [listaReservas, setListaReservas] = useState(reservasIniciales);
const [idSeleccionada, setIdSeleccionada] = useState(null);   // el ID, no el objeto (principio 2)
const [horas, setHoras] = useState(2);
const [envio, setEnvio] = useState({ estado: 'inactivo', error: null });
// estado: 'inactivo' | 'enviando' | 'enviado' | 'error'   (principio 3)

// DERIVADOS: no son estado (principio 4)
const reservaSeleccionada = listaReservas.find((r) => r.id === idSeleccionada) ?? null;
const bicicleta = reservaSeleccionada
  ? bicicletas.find((b) => b.id === reservaSeleccionada.bicicletaId)
  : null;
const precioTotal = bicicleta ? horas * bicicleta.precioHora : 0;
const estaEnviando = envio.estado === 'enviando';

function manejarSeleccion(idReserva) {
  setIdSeleccionada(idReserva);
  setEnvio({ estado: 'inactivo', error: null });
}

function manejarCambioHoras(nuevasHoras) {
  setHoras(Math.max(1, nuevasHoras));
}

async function manejarConfirmar() {
  setEnvio({ estado: 'enviando', error: null });
  try {
    await enviarReserva({ id: idSeleccionada, horas, total: precioTotal });
    setListaReservas((previas) =>
      previas.map((reserva) =>
        reserva.id === idSeleccionada ? { ...reserva, horas, estado: 'confirmada' } : reserva
      )
    );
    setEnvio({ estado: 'enviado', error: null });
  } catch (error) {
    setEnvio({ estado: 'error', error: error.message });
  }
}

Los cambios, principio a principio: reservaSeleccionada deja de duplicar el objeto y pasa a derivarse del id (2); precioTotal se calcula en cada render en lugar de guardarse (4); y los cuatro booleanos de envío se funden en una sola variable con cuatro valores posibles, de modo que ya no existe la combinación «enviando y con error a la vez» (3). Además, envio agrupa estado y mensaje, que siempre cambian juntos (1). Con setEnvio({ estado: 'error', error: error.message }) es imposible olvidar apagar el indicador de carga.

Solución 3.

const [listaReservas, setListaReservas] = useState(reservas);

function manejarConfirmar(idReserva) {
  setListaReservas((previas) =>
    previas.map((reserva) =>
      reserva.id === idReserva ? { ...reserva, estado: 'confirmada' } : reserva
    )
  );
}

function manejarCancelar(idReserva) {
  setListaReservas((previas) =>
    previas.map((reserva) =>
      reserva.id === idReserva
        ? { ...reserva, estado: 'cancelada', canceladaEn: new Date().toISOString() }
        : reserva
    )
  );
}

function manejarPurgar() {
  setListaReservas((previas) => previas.filter((reserva) => reserva.estado !== 'cancelada'));
}

Tres detalles que valen la nota: el map devuelve la misma referencia para las reservas no afectadas, así que solo cambia el objeto que realmente cambió; new Date().toISOString() se ejecuta dentro del manejador y no durante el render, respetando la pureza (04-03); y filter devuelve un array nuevo por definición, así que no hace falta copiarlo antes.

Conclusión

useState es mucho más de lo que parecía en 02-04. Ahora sabes que devuelve un par [valor, actualizador] en el que el actualizador es estable para siempre, que el valor es una instantánea congelada del render en curso y que por eso tres setContador(contador + 1) seguidos suman uno solo. La forma funcional resuelve ese caso porque encola funciones que React aplica en cadena; la agrupación de actualizaciones —automática en todas partes desde React 18— convierte varios cambios en un único render; y la inicialización perezosa evita repetir cálculos caros en cada repintado. Tienes además el recetario completo de actualización inmutable para arrays y objetos aplicado a las reservas de CicloUrbano, cinco principios para estructurar el estado sin duplicados, contradicciones ni redundancias, y el truco de la prop key para reiniciar un componente entero cuando cambia lo que representa.

Con esto puedes gestionar cualquier dato que viva dentro de React. Pero una aplicación real no vive sola: hay temporizadores que marcan la disponibilidad de las estaciones, eventos del navegador, un título de pestaña que actualizar y, sobre todo, un servidor del que traer las bicicletas. Todo eso queda fuera de React, y conectarse a ello tiene sus propias reglas: cuándo empezar, cuándo parar y cómo no dejar basura por el camino. Es el modelo de sincronización con sistemas externos que se anunció en 04-03 y que por fin toca desarrollar. La próxima lección es Hook useEffect.

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