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
- La firma de
useStatey el par que devuelve - Por qué el actualizador es estable entre renders
- El estado es una instantánea del render
- Tres
setContador(contador + 1)que suman uno solo - La forma funcional y la cola de actualizaciones
- Agrupación de actualizaciones (batching)
- Inicialización perezosa y la trampa del cálculo directo
- Objetos y arrays: recetario de actualización inmutable
- Estructurar bien el estado: cinco principios
- Reiniciar el estado de un componente con la prop
key - Valor directo o función actualizadora: tabla de decisión
- La firma de
useState y el par que devuelve
useState y el par que devuelveuseState 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:
- 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
useStatees 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, siempreTres 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 estableLa misma garantía se aplica a la función despachar de useReducer, como verás en 05-05.
- 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.
- Tres
setContador(contador + 1) que suman uno solo
setContador(contador + 1) que suman uno soloEste 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».
- 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 puestoReglas para escribir buenas funciones actualizadoras:
- Deben ser puras: calcular y devolver el nuevo estado, sin
console.logque dependan del orden, sin peticiones, sin mutar nada. React puede llamarlas dos veces en desarrollo conStrictMode(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).
- 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.
- 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).
- 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'
}
];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.
- 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.
- 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 });
- 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.
- 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
- 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;
- 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.
- Reiniciar el estado de un componente con la prop
key
keyReact 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 sobrevivePodrí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.
- 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 unifque rompe el bucle. useState(calcular())en lugar deuseState(() => 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,erroryexitocomo 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 aundefined. - 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
- ¿Qué es React?
- Configuración del Entorno de Desarrollo
- Hola Mundo en React
- JSX: Extensión de Sintaxis de JavaScript
- Cómo Renderiza React: Virtual DOM y Reconciliación
Módulo 2: Componentes de React
- Entendiendo los Componentes
- Componentes Funcionales vs de Clase
- Props: Pasando Datos a Componentes
- State: Gestión del Estado del Componente
- Estilos en los Componentes: CSS, Módulos y Utilidades
Módulo 3: Trabajando con Eventos
- Manejo de Eventos en React
- Renderizado Condicional
- Listas y Claves
- Formularios y Componentes Controlados
- Validación de Formularios y Componentes No Controlados
- Accesibilidad en Componentes Interactivos
Módulo 4: Conceptos Avanzados de Componentes
- Elevando el Estado
- Composición vs Herencia
- Métodos del Ciclo de Vida de React
- Hooks: Introducción y Uso Básico
- Límites de Error: Capturar Fallos en la Interfaz
Módulo 5: Hooks de React
- Hook useState
- Hook useEffect
- Hook useRef y Acceso al DOM
- Hook useContext
- Hook useReducer
- Hooks Personalizados
Módulo 6: Enrutamiento en React
- Introducción a React Router
- Configuración de React Router
- Rutas Anidadas
- Navegación Programática
- Rutas Protegidas y Control de Acceso
Módulo 7: Gestión del Estado
- Introducción a la Gestión del Estado
- API de Contexto
- Redux: Introducción y Configuración
- Redux: Acciones y Reductores
- Redux: Conectando a React
- Estado del Servidor: Peticiones, Caché y Sincronización
Módulo 8: Optimización del Rendimiento
- Técnicas de Optimización del Rendimiento en React
- Memorización con React.memo
- Hooks useMemo y useCallback
- División de Código y Carga Perezosa
- Medir el Rendimiento con React DevTools Profiler
Módulo 9: Pruebas en React
- Introducción a las Pruebas
- Pruebas Unitarias con Jest
- Pruebas de Componentes con React Testing Library
- Pruebas de Código Asíncrono y Simulación de APIs
- Pruebas de Extremo a Extremo con Cypress
Módulo 10: Temas Avanzados
- Renderizado del Lado del Servidor (SSR) con Next.js
- Generación de Sitios Estáticos (SSG) con Next.js
- Suspense y React Server Components
- TypeScript con React
- React Native: Creación de Aplicaciones Móviles
