Módulo 04: Estado con useState

Objetivos del módulo

  • Entender por qué una variable normal no sirve para guardar datos que cambian en pantalla
  • Declarar estado con useState, tiparlo con TypeScript y usar la inicialización perezosa
  • Conocer las reglas de los hooks y la razón por la que existen
  • Ver que cada render trabaja con una instantánea del estado
  • Saber cómo agrupa React las actualizaciones por lotes y cuándo se vuelve a renderizar
  • Usar la función actualizadora cuando el valor nuevo depende del anterior
  • Actualizar objetos y arrays sin mutarlos, incluidos los datos anidados
  • Calcular el estado derivado en lugar de guardarlo
  • Elevar el estado al ancestro común para que varios componentes compartan una única fuente de verdad
  • Diseñar la estructura del estado para que no pueda quedar en situaciones imposibles
  • Reconocer y corregir los errores más frecuentes con el estado

💡 Consejo: Este módulo no lleva marcas 🆕 ni 🔄. useState existe desde React 16.8 y todo lo que se enseña aquí funciona igual en React 18.3 y en React 19.3. Hemos ejecutado la misma prueba de lotes y actualizadoras en 18.3.1 y en 19.3.1, y el resultado es idéntico.


1. Por qué no basta con una variable

Hasta ahora tus componentes solo mostraban lo que recibían por props. Son como una fórmula: entran datos y sale interfaz. Pero una aplicación de gestión necesita recordar cosas: qué pestaña está abierta, qué ha escrito el usuario en el buscador, qué empleado está seleccionado o cuántas unidades hay en el pedido. Lo primero que se nos ocurre es usar una variable:

// ❌ MAL: la pantalla no cambia nunca
export default function Asistencia() {
  let pulsaciones = 0;
  console.log('render: pulsaciones =', pulsaciones);

  function pulsar() {
    pulsaciones = pulsaciones + 1;
    console.log('clic: pulsaciones =', pulsaciones);
  }

  return (
    <section className="tarjeta">
      <h2>Registro de asistencia</h2>
      <p>Has fichado {pulsaciones} veces</p>
      <button onClick={pulsar}>Fichar</button>
    </section>
  );
}

onClick conecta el botón con una función; los eventos se estudian a fondo en el módulo 05. Al pulsar tres veces, la consola muestra esto, mientras la pantalla sigue diciendo “Has fichado 0 veces”:

render: pulsaciones = 0
render: pulsaciones = 0
clic: pulsaciones = 1
clic: pulsaciones = 2
clic: pulsaciones = 3

La línea de render sale dos veces porque la plantilla activa <StrictMode>, que en desarrollo renderiza cada componente dos veces para destapar impurezas. Lo veremos en el módulo 05. Lo importante es que la variable sí cambia, y aun así no ocurre nada. Hay dos problemas independientes:

  1. Nadie avisa a React. Cambiar una variable de JavaScript no provoca nada. React no vigila tus variables, así que no sabe que debe volver a llamar al componente.
  2. La variable no sobrevive al render. Aunque React volviera a llamar a Asistencia, la primera línea ejecutaría de nuevo let pulsaciones = 0. Un componente es una función, y sus variables locales nacen y mueren con cada llamada.

¿Y si sacamos la variable fuera del componente? Eso resuelve el segundo problema, pero no el primero, y además crea otro: esa variable sería compartida por todas las instancias del componente. Dos <Asistencia /> en la misma página contarían lo mismo.

Dónde guardas el dato ¿Sobrevive al render? ¿Actualiza la pantalla? ¿Una copia por instancia?
Variable dentro del componente No No Sí
Variable fuera del componente Sí No No
Estado con useState Sí Sí Sí

📘 Concepto: El estado es la memoria de un componente. Son datos que React guarda fuera de la función, que se conservan entre renders y cuyo cambio hace que React vuelva a renderizar el componente.

Prepara el proyecto

Para probar los ejemplos crea un proyecto como en el módulo 01, donde se explica también qué cambia si usas PowerShell:

npm create vite@latest modulo-04 -- --template react-ts
cd modulo-04
npm install
npm run dev

Cada ejemplo completo de este módulo sustituye a src/App.tsx. El nombre de la función exportada da igual, porque main.tsx la importa como exportación por defecto. Borra src/App.css y sustituye src/index.css por estas reglas, que usan todos los ejemplos:

:root {
  font-family: 'Segoe UI', system-ui, sans-serif;
  color: #263238;
  background: #f5f6fa;
}

body {
  margin: 0;
  padding: 1.25rem;
}

.tarjeta {
  background: #fff;
  border: 1px solid #c5cae9;
  border-radius: 10px;
  padding: 1rem 1.25rem;
  max-width: 620px;
  margin: 0 0 1rem;
}

.tarjeta h2 {
  margin: 0 0 0.5rem;
  font-size: 1.15rem;
  color: #1a237e;
}

.fila {
  display: flex;
  gap: 0.5rem;
  align-items: center;
  flex-wrap: wrap;
  margin-bottom: 0.75rem;
}

button,
input,
select {
  font: inherit;
  padding: 0.35rem 0.7rem;
  border: 1px solid #3f51b5;
  border-radius: 6px;
  background: #fff;
  color: #1a237e;
}

button {
  cursor: pointer;
  background: #e8eaf6;
}

button:disabled {
  cursor: not-allowed;
  opacity: 0.5;
}

button[aria-pressed='true'] {
  background: #3f51b5;
  color: #fff;
}

table {
  border-collapse: collapse;
  width: 100%;
}

th,
td {
  text-align: left;
  padding: 0.35rem 0.5rem;
  border-bottom: 1px solid #e8eaf6;
}

Los datos de los ejemplos se inspiran en la base de datos EMPRESA del curso, recortados para que quepan en el listado, y van escritos en el propio código. Pedirlos a la API exige efectos, que llegan en el módulo 09.


2. useState paso a paso

Corregimos el ejemplo anterior con el hook useState:

import { useState } from 'react';

export default function AsistenciaBien() {
  const [pulsaciones, setPulsaciones] = useState(0);

  function pulsar() {
    setPulsaciones(pulsaciones + 1);
  }

  return (
    <section className="tarjeta">
      <h2>Registro de asistencia</h2>
      <p>Has fichado {pulsaciones} veces</p>
      <button onClick={pulsar}>Fichar</button>
    </section>
  );
}

Una tarjeta con el texto Has fichado 3 veces y un botón Fichar

Figura 1. Tras tres pulsaciones, ahora la pantalla sí refleja el dato.

Un hook es una función especial de React, cuyo nombre empieza por use, que permite a un componente engancharse a una capacidad de React. useState le da memoria. Analicemos la línea clave:

const [pulsaciones, setPulsaciones] = useState(0);
  • useState(0) declara una variable de estado cuyo valor inicial es 0.
  • Devuelve un array de dos elementos, que separamos con desestructuración de arrays (repásala en el Curso de JavaScript).
  • pulsaciones es el valor en este render.
  • setPulsaciones es la función de asignación (setter). Guarda el valor nuevo y pide a React otro render.

La convención es algo y setAlgo. React no te obliga, pero todo el mundo lo escribe así y las herramientas lo esperan.

Esto es lo que ocurre al pulsar el botón:

  1. React llama a AsistenciaBien. useState(0) no encuentra nada guardado, así que usa el 0 y devuelve [0, setter].
  2. El navegador muestra “Has fichado 0 veces”.
  3. El usuario pulsa. pulsar llama a setPulsaciones(1).
  4. React guarda el 1 y programa otro render.
  5. React vuelve a llamar a AsistenciaBien. Ahora useState(0) ignora el 0 y devuelve el valor guardado, [1, setter].
  6. React compara el resultado con lo que había y cambia en el DOM solo el texto.

Ciclo de cuatro pasos: render con el estado actual, el usuario actúa, el setter guarda el valor nuevo, React renderiza de nuevo

Figura 2. El setter no toca la pantalla. Guarda el dato y pide otro render; la pantalla es siempre el resultado de renderizar con el estado guardado.

📘 Concepto: En React no le dices a la pantalla qué debe cambiar. Cambias el estado, y la pantalla se recalcula a partir de él. Esta es la versión práctica de la idea UI = f(estado) que viste en el módulo 00.

El tipo de useState

Estas son las declaraciones reales de @types/react, simplificadas:

type SetStateAction<S> = S | ((prevState: S) => S);
type Dispatch<A> = (value: A) => void;

function useState<S>(initialState: S | (() => S)): [S, Dispatch<SetStateAction<S>>];
function useState<S = undefined>(): [S | undefined, Dispatch<SetStateAction<S | undefined>>];

Se leen así: el valor inicial puede ser un valor o una función que lo calcula, y el setter acepta un valor o una función que recibe el valor anterior. Ambas funciones tienen su sección más adelante.

Tipar el estado

Casi siempre TypeScript deduce el tipo a partir del valor inicial. Debes indicarlo tú, con el genérico useState<Tipo>, cuando el valor inicial no dice todo lo que el estado puede llegar a contener:

// Deducido: no hace falta escribir nada
const [nombre, setNombre] = useState('Ana');         // string
const [salario, setSalario] = useState(6500);        // number
const [activo, setActivo] = useState(true);          // boolean

// Explícito: el valor inicial se queda corto
const [seleccionado, setSeleccionado] = useState<Empleado | null>(null);
const [empleados, setEmpleados] = useState<Empleado[]>([]);
const [orden, setOrden] = useState<'nombre' | 'salario'>('nombre');
Caso Sin genérico Con genérico
useState(null) null: nunca podrás guardar otra cosa useState<Empleado \| null>(null)
useState([]) never[]: setLista(['a']) no compila useState<string[]>([])
useState('nombre') string: admite cualquier texto useState<'nombre' \| 'salario'>('nombre')
useState<string>()   string \| undefined: evítalo en campos de formulario (sección 14)

⚠️ Importante: Al leer un estado Empleado | null, TypeScript te obliga a comprobar el null. Es una ayuda, no una molestia: te recuerda que hay un render en el que todavía no hay nadie seleccionado.

El valor inicial solo se usa una vez

El argumento de useState solo importa en el primer render. En los siguientes React lo descarta, aunque haya cambiado. Esto tiene dos consecuencias.

La primera es un error típico, que veremos en la sección 10: inicializar el estado con una prop no hace que siga a esa prop.

La segunda es de rendimiento. En esta línea, leerBorrador() se ejecuta en cada render, aunque su resultado solo se use en el primero:

const [borrador, setBorrador] = useState(leerBorrador());

Si el cálculo es caro, pasa la función sin llamarla. Se llama inicialización perezosa: React solo la invoca en el primer render.

const [borrador, setBorrador] = useState(leerBorrador);

// Si la función necesita argumentos, envuélvela en otra:
const [borrador, setBorrador] = useState(() => leerBorrador('empleado'));

Comprobémoslo:

import { useState } from 'react';

function leerBorrador() {
  console.log('leerBorrador: se ejecuta');
  return 'Nota sin terminar';
}

export default function Perezosa() {
  const [mala] = useState(leerBorrador());
  const [buena] = useState(leerBorrador);
  const [renders, setRenders] = useState(0);

  return (
    <section className="tarjeta">
      <p>{mala} / {buena}</p>
      <button onClick={() => setRenders(renders + 1)}>Renders: {renders}</button>
    </section>
  );
}

Sin Strict Mode y tras dos clics, la consola muestra cuatro líneas: dos del primer render (una por cada useState) y una por cada clic, que proceden de la versión useState(leerBorrador()):

leerBorrador: se ejecuta
leerBorrador: se ejecuta
leerBorrador: se ejecuta
leerBorrador: se ejecuta

Con Strict Mode verías además una llamada doble al inicializador. Es normal: te ayuda a detectar si no es puro.


3. Las reglas de los hooks

Los hooks tienen dos reglas:

  1. Llámalos solo en el nivel superior. Nunca dentro de condiciones, bucles, funciones anidadas ni después de un return anticipado.
  2. Llámalos solo desde componentes o desde otros hooks. Nunca desde funciones normales.

No son manías. Se deducen de cómo funciona useState. Fíjate en que cuando escribes dos estados, ninguno lleva un nombre que React pueda ver:

const [nombre, setNombre] = useState('Ana');
const [salario, setSalario] = useState(6500);

El nombre nombre es de tu variable, no de React. Entonces, ¿cómo sabe React cuál es cuál? Por el orden de llamada. Por cada componente montado, React guarda una lista de casillas. La primera llamada a useState usa la primera casilla; la segunda, la segunda.

Un useState de juguete

El mecanismo cabe en pocas líneas. Este useState es una versión simplificada para entender la idea; el de verdad guarda una lista por instancia, encola las actualizaciones y no renderiza de forma inmediata. Puedes ejecutarlo con Node 24, que entiende estos tipos sencillos sin configurar nada: node mini-react.ts.

// mini-react.ts
const casillas: unknown[] = [];
let cursor = 0;

function useState<T>(inicial: T): [T, (nuevo: T) => void] {
  const indice = cursor;      // esta llamada se queda con la casilla actual
  cursor = cursor + 1;        // la siguiente llamada usará la siguiente

  if (!(indice in casillas)) {
    casillas[indice] = inicial; // solo la primera vez
  }

  function fijar(nuevo: T) {
    casillas[indice] = nuevo;
    renderizar();
  }

  return [casillas[indice] as T, fijar];
}

function Ficha() {
  const [nombre, setNombre] = useState('Ana');
  const [salario, setSalario] = useState(6500);
  console.log(`render: ${nombre}, ${salario}`);
  return { setNombre, setSalario };
}

function renderizar() {
  cursor = 0; // cada render vuelve a empezar por la primera casilla
  return Ficha();
}

const primero = renderizar();
primero.setSalario(7000);
primero.setNombre('Luis');
render: Ana, 6500
render: Ana, 7000
render: Luis, 7000

Aquí se explica también lo visto en la sección 2: el valor inicial solo se usa si la casilla está vacía. Y se ve por qué importa el orden: indice depende únicamente de cuántos hooks se han llamado antes.

Qué pasa si rompes la regla

Mete un useState dentro de un if:

// ❌ MAL: viola las reglas de los hooks
const [conNota, setConNota] = useState(false);
if (conNota) {
  const [nota, setNota] = useState('');   // solo existe a veces
}
const [salario, setSalario] = useState(6500);

Con conNota a false, salario ocupa la casilla 2. Cuando conNota pasa a true, nota se queda con la casilla 2 y salario salta a la 3, una casilla que no existe.

Dos columnas de casillas: en el primer render la casilla 2 es salario; en el segundo render la casilla 2 es nota y salario queda desplazado a una casilla nueva

Figura 3. React asocia cada useState a una posición. Si un hook aparece o desaparece, todos los siguientes reciben la casilla de otro.

En la práctica, React detecta el problema y falla con un mensaje claro:

React has detected a change in the order of Hooks called by Ficha.
   Previous render            Next render
   ------------------------------------------------------
1. useState                   useState
2. useState                   useState
3. undefined                  useState

Error: Rendered more hooks than during the previous render.

El caso contrario, un return anticipado antes de un hook, produce el error simétrico:

Error: Rendered fewer hooks than expected. This may be caused by an accidental early return statement.

Si necesitas algo condicional, llama al hook siempre y pon la condición en lo que hagas con su valor, o bien saca esa parte a un componente hijo que solo se renderice a veces:

// ✅ El hook se llama siempre; la condición va en la interfaz
const [conNota, setConNota] = useState(false);
const [nota, setNota] = useState('');
const [salario, setSalario] = useState(6500);

return conNota ? <input value={nota} onChange={(e) => setNota(e.target.value)} /> : null;

💡 Consejo: No tienes que vigilar esto a ojo. La regla rules-of-hooks del linter detecta estos fallos mientras escribes. oxlint, el de la plantilla, dice esto al ejecutar npm run lint:

react-hooks(rules-of-hooks): React Hook "useState" is called conditionally. React Hooks must be called in the exact same order in every component render.

Hay una excepción: use, que veremos en el módulo 15, sí puede llamarse dentro de condicionales.


4. El estado es local y privado

El estado pertenece a cada instancia del componente en pantalla. Si renderizas el mismo componente dos veces, cada copia tiene sus propias casillas:

import { useState } from 'react';

function Marcador({ etiqueta }: { etiqueta: string }) {
  const [ventas, setVentas] = useState(0);

  return (
    <div className="fila">
      <strong>{etiqueta}:</strong> {ventas} {ventas === 1 ? 'venta' : 'ventas'}
      <button onClick={() => setVentas(ventas + 1)}>+1</button>
    </div>
  );
}

export default function Marcadores() {
  const [verElena, setVerElena] = useState(true);

  return (
    <section className="tarjeta">
      <h2>Ventas del día</h2>
      <Marcador etiqueta="Carmen Ruiz" />
      {verElena && <Marcador etiqueta="Elena Navarro" />}
      <label>
        <input
          type="checkbox"
          checked={verElena}
          onChange={(e) => setVerElena(e.target.checked)}
        />{' '}
        Mostrar a Elena
      </label>
    </section>
  );
}

Una tarjeta con dos marcadores: Carmen Ruiz con 3 ventas y Elena Navarro con 1 venta

Figura 4. Dos instancias del mismo componente con estados independientes.

Hay tres cosas que observar:

  • Es independiente. Pulsar en Carmen no afecta a Elena.
  • Es privado. Marcadores no sabe cuántas ventas lleva cada una. Ningún padre puede leer el estado de su hijo. Si lo necesita, hay que elevarlo (sección 11).
  • Vive mientras el componente esté en pantalla. Si desmarcas la casilla, el segundo Marcador desaparece y su estado se destruye. Al marcarla otra vez, Elena vuelve con 0 ventas.

Las reglas exactas de cuándo se conserva o se pierde el estado dependen de la posición en el árbol y de key, y se explican en el módulo 05.


5. Cada render ve una instantánea

Este es el malentendido más frecuente de quien empieza. Mira este código y piensa qué saldrá en la consola:

import { useState } from 'react';

export default function Instantanea() {
  const [contador, setContador] = useState(0);

  function pulsar() {
    setContador(contador + 1);
    console.log('justo después del setter:', contador);

    setTimeout(() => {
      console.log('medio segundo después:', contador);
    }, 500);
  }

  return (
    <section className="tarjeta">
      <p>Contador: {contador}</p>
      <button onClick={pulsar}>Sumar</button>
    </section>
  );
}

Tras dos clics seguidos:

justo después del setter: 0
justo después del setter: 1
medio segundo después: 0
medio segundo después: 1

El primer clic imprime 0 en las dos líneas, aunque el segundo mensaje se escriba cuando la pantalla ya muestra 2. No es un fallo. contador es una constante de ese render: lleva const, así que el setter no podría cambiarla aunque quisiera. Lo que hace el setter es pedir un render nuevo, en el que useState devolverá otro valor a otra constante, también llamada contador.

Cada render es una llamada a la función con sus propias constantes. Las funciones creadas durante ese render, como pulsar y la del setTimeout, quedan atadas a esas constantes por el mecanismo de clausura. Por eso el temporizador ve el valor del render en el que nació, por muy tarde que se ejecute.

📘 Concepto: El estado se comporta como una fotografía. Cada render recibe una foto fija, y nada de lo que hagas dentro de ese render la modifica. El setter encarga la foto siguiente.

Si necesitas el valor nuevo en el mismo manejador, calcúlalo antes y úsalo:

function pulsar() {
  const siguiente = contador + 1;
  setContador(siguiente);
  guardarEnServidor(siguiente); // no uses contador aquí: es el valor antiguo
}

El módulo 05 estudia a fondo el ciclo trigger, render y commit y las instantáneas.


6. Actualizaciones por lotes

Si un manejador llama a tres setters, ¿hay tres renders? No. React espera a que termine el código en curso y aplica todos los cambios juntos en un único render. A esto se le llama agrupar por lotes (batching). Es como un camarero que apunta toda la comanda antes de ir a la cocina, en lugar de hacer un viaje por cada plato.

import { useState } from 'react';
import { flushSync } from 'react-dom';

const esperar = (ms: number) => new Promise((resolver) => setTimeout(resolver, ms));

export default function Lotes() {
  const [altas, setAltas] = useState(0);
  const [bajas, setBajas] = useState(0);
  const [cambios, setCambios] = useState(0);

  console.log(`render: altas=${altas} bajas=${bajas} cambios=${cambios}`);

  function sumarLasTres() {
    setAltas((n) => n + 1);
    setBajas((n) => n + 1);
    setCambios((n) => n + 1);
  }

  function enManejador() {
    console.log('--- en el manejador');
    sumarLasTres();
  }

  function enTemporizador() {
    console.log('--- en setTimeout');
    setTimeout(sumarLasTres, 0);
  }

  function enPromesa() {
    console.log('--- tras una promesa');
    Promise.resolve().then(sumarLasTres);
  }

  async function conAwait() {
    console.log('--- antes y después de await');
    setAltas((n) => n + 1);
    setBajas((n) => n + 1);
    await esperar(100);
    setCambios((n) => n + 1);
  }

  function sinLotes() {
    console.log('--- con flushSync');
    flushSync(() => setAltas((n) => n + 1));
    flushSync(() => setBajas((n) => n + 1));
  }

  return (
    <section className="tarjeta">
      <h2>Altas {altas}, bajas {bajas}, cambios {cambios}</h2>
      <div className="fila">
        <button onClick={enManejador}>Manejador</button>
        <button onClick={enTemporizador}>setTimeout</button>
        <button onClick={enPromesa}>Promesa</button>
        <button onClick={conAwait}>Con await</button>
        <button onClick={sinLotes}>flushSync</button>
      </div>
    </section>
  );
}

Para contar renders sin que Strict Mode los duplique, quita <StrictMode> de main.tsx durante esta prueba. Pulsando cada botón una vez:

render: altas=0 bajas=0 cambios=0
--- en el manejador
render: altas=1 bajas=1 cambios=1
--- en setTimeout
render: altas=2 bajas=2 cambios=2
--- tras una promesa
render: altas=3 bajas=3 cambios=3
--- antes y después de await
render: altas=4 bajas=4 cambios=3
render: altas=4 bajas=4 cambios=4
--- con flushSync
render: altas=5 bajas=4 cambios=4
render: altas=5 bajas=5 cambios=4

La salida enseña tres cosas:

  • Hay un render por lote, no por setter. Tres setters seguidos cuestan un solo render, tanto en un manejador como en setTimeout o tras una promesa.
  • await parte el lote. Lo que hay antes se aplica cuando la función se detiene en el await, y lo de después forma otro lote. Esto es justo lo que necesitas para mostrar “Guardando…” mientras esperas al servidor.
  • flushSync fuerza un render inmediato. Es una vía de escape, poco habitual, que se usa sobre todo para medir el DOM. Se explica en el módulo 10.

Antes de React 18, según su anuncio oficial, solo se agrupaban las actualizaciones dentro de manejadores de eventos de React. Las de promesas y setTimeout causaban un render cada una. Por eso aún encontrarás explicaciones antiguas que lo cuentan así.

Agrupar por lotes evita además estados intermedios en pantalla. Si altas y cambios deben cambiar juntos, nunca verás un fotograma con uno sí y el otro no.


7. La función actualizadora

Mira este botón, que pretende sumar tres:

function sumarTres() {
  setUnidades(unidades + 1);
  setUnidades(unidades + 1);
  setUnidades(unidades + 1);
}

Solo suma uno. Con la instantánea de la sección 5 lo entiendes: si unidades vale 0, las tres líneas dicen lo mismo, setUnidades(0 + 1). Le pides tres veces a React que guarde un 1.

La solución es pasar al setter una función actualizadora. En lugar de decir “guarda este valor”, dices “calcula el valor nuevo a partir del que haya”:

import { useState } from 'react';

export default function Unidades() {
  const [unidades, setUnidades] = useState(0);

  function sumarTresMal() {
    setUnidades(unidades + 1);
    setUnidades(unidades + 1);
    setUnidades(unidades + 1);
  }

  function sumarTresBien() {
    setUnidades((actual) => actual + 1);
    setUnidades((actual) => actual + 1);
    setUnidades((actual) => actual + 1);
  }

  function sumarConRetraso() {
    setTimeout(() => setUnidades(unidades + 1), 300);
  }

  function sumarConRetrasoBien() {
    setTimeout(() => setUnidades((actual) => actual + 1), 300);
  }

  function mezclar() {
    setUnidades(5);
    setUnidades((actual) => actual + 1);
    setUnidades(42);
    setUnidades((actual) => actual * 2);
  }

  return (
    <section className="tarjeta">
      <h2>Unidades: {unidades}</h2>
      <div className="fila">
        <button onClick={sumarTresMal}>+3 mal</button>
        <button onClick={sumarTresBien}>+3 bien</button>
        <button onClick={sumarConRetraso}>+1 con retraso</button>
        <button onClick={sumarConRetrasoBien}>+1 con retraso bien</button>
        <button onClick={mezclar}>Mezclar</button>
        <button onClick={() => setUnidades(0)}>Poner a cero</button>
      </div>
    </section>
  );
}

Resultados medidos desde 0:

Acción Resultado
“+3 mal” 1
“+3 bien” 3
Tres clics rápidos en “+1 con retraso” 1
Tres clics rápidos en “+1 con retraso bien” 3
“Mezclar” 84

Qué hace React por dentro

Un setter no cambia el estado en el momento: añade una entrada a una cola de actualizaciones de esa casilla. Durante el siguiente render, React recorre la cola en orden:

  • Si la entrada es un valor, sustituye al valor acumulado.
  • Si es una función, la llama con el valor acumulado y se queda con el resultado.

Dos colas de actualizaciones. Con valores: tres veces guardar 1, resultado 1. Con funciones: tres veces sumar uno, resultado 3

Figura 5. Los valores se pisan unos a otros; las funciones se encadenan.

Así se resuelve “Mezclar”:

Entrada en la cola Valor acumulado antes Resultado
Sustituir por 5 0 5
actual => actual + 1 5 6
Sustituir por 42 6 42
actual => actual * 2 42 84

El caso del temporizador es el mismo problema con otra apariencia. Cada clic crea un temporizador atado a su propia instantánea. Si los tres clics ocurren antes de que venza el primero, los tres ven unidades = 0. A estas funciones que ven un valor antiguo se las llama clausuras obsoletas (stale closures).

Cuándo usarla

Situación Qué escribir
El valor nuevo no depende del anterior setVista('tabla')
El valor nuevo sale de un evento setTexto(e.target.value)
Depende del anterior y puede haber varias actualizaciones seguidas setN((actual) => actual + 1)
Se ejecuta más tarde: temporizador, await, promesa setN((actual) => actual + 1)

Algunos equipos usan siempre la forma con función cuando el valor depende del anterior, por ser la más segura. Es una buena costumbre. En los demás casos, escribe el valor.

⚠️ Importante: La función actualizadora debe ser pura: solo calcula y devuelve. No hagas en ella fetch ni modifiques nada de fuera. En Strict Mode React la invoca dos veces para destapar este tipo de errores, como muestra esta traza de un clic:

actualizadora 0
actualizadora 0
render 1
render 1

Cuándo no hay render

Antes de encolar, React compara el valor nuevo con el actual usando Object.is. Si son iguales, no hay nada que actualizar. Compruébalo:

import { useState } from 'react';

function Hijo() {
  console.log('  render de Hijo');
  return <p>Soy el hijo</p>;
}

export default function MismoValor() {
  const [valor, setValor] = useState(0);
  console.log('render de MismoValor, valor =', valor);

  return (
    <section className="tarjeta">
      <Hijo />
      <div className="fila">
        <button onClick={() => { console.log('--- poner 0'); setValor(0); }}>Poner 0</button>
        <button onClick={() => { console.log('--- poner 1'); setValor(1); }}>Poner 1</button>
      </div>
    </section>
  );
}

Sin Strict Mode, pulsando “Poner 0” una vez y “Poner 1” tres veces:

render de MismoValor, valor = 0
  render de Hijo
--- poner 0
--- poner 1
render de MismoValor, valor = 1
  render de Hijo
--- poner 1
render de MismoValor, valor = 1
--- poner 1

Fíjate en el segundo “poner 1”: React vuelve a llamar a MismoValor, pero no a Hijo, y descarta el resultado. Es un detalle interno, documentado en la referencia de useState: a veces necesita llamar al componente una vez más antes de descartar el cambio. No afecta al DOM ni a los hijos, así que no intentes “arreglarlo”.

Object.is funciona casi como ===, con dos diferencias: Object.is(NaN, NaN) es true y Object.is(0, -0) es false. Lo importante viene en la siguiente sección: para objetos y arrays, compara referencias, no contenido.


8. Objetos en el estado sin mutarlos

El estado puede contener cualquier valor de JavaScript. Pero los objetos y los arrays se pueden modificar por dentro (mutar), a diferencia de los números o los textos. En React, el estado se trata siempre como si fuera de solo lectura.

Veamos qué pasa si mutas:

import { useState } from 'react';

interface FichaEmpleado {
  nombre: string;
  oficio: string;
  salario: number;
}

export default function Mutacion() {
  const [ficha, setFicha] = useState<FichaEmpleado>({
    nombre: 'Elena Navarro',
    oficio: 'VENDEDOR',
    salario: 2300,
  });
  const [visitas, setVisitas] = useState(0);

  function subirMal() {
    ficha.salario = ficha.salario + 100; // ❌ muta el objeto del estado
    setFicha(ficha);                     // ❌ misma referencia
  }

  function subirBien() {
    setFicha({ ...ficha, salario: ficha.salario + 100 }); // ✅ objeto nuevo
  }

  return (
    <section className="tarjeta">
      <h2>{ficha.nombre}</h2>
      <p>{ficha.oficio}: {ficha.salario} €</p>
      <div className="fila">
        <button onClick={subirMal}>Subir mal</button>
        <button onClick={subirBien}>Subir bien</button>
        <button onClick={() => setVisitas(visitas + 1)}>Otra cosa ({visitas})</button>
      </div>
    </section>
  );
}

Al pulsar “Subir mal” dos veces, la pantalla sigue en 2300 €. Al pulsar luego “Otra cosa”, aparece de repente 2500 €. El dato había cambiado, pero nadie se enteró:

  1. setFicha(ficha) compara con Object.is. Es el mismo objeto, así que para React no hay cambios y no renderiza.
  2. Cuando otro estado provoca un render, se lee el objeto ya alterado.

Este es el peor tipo de fallo: la interfaz va por detrás de los datos y salta en momentos sin relación aparente.

📘 Concepto: Para actualizar un objeto del estado, crea uno nuevo y pásalo al setter. La sintaxis de propagación (...ficha) copia todas las propiedades, y las que escribes después sustituyen a las copiadas.

Además de que el render ocurra, hay otras razones para no mutar:

  • Las instantáneas siguen siendo fiables. Si mutas, un render antiguo y un temporizador pendiente ven datos que nunca fueron “los suyos”.
  • Comparar es barato. Si la referencia es distinta, algo ha cambiado. Sin comprobar propiedad por propiedad. En eso se apoyan memo y React Compiler (módulo 14).
  • El historial sale gratis. Si nunca modificas lo anterior, para tener “deshacer” basta con guardar los objetos viejos (ejercicio 11).

Un manejador para varios campos

Con un objeto y una propiedad calculada, un solo manejador sirve para todos los campos. Los formularios se tratan a fondo en el módulo 08; por ahora basta saber que un campo value + onChange muestra el estado y lo actualiza al escribir:

import { useState, type ChangeEvent } from 'react';

interface DatosContacto {
  nombre: string;
  email: string;
  telefono: string;
}

export default function FichaContacto() {
  const [datos, setDatos] = useState<DatosContacto>({
    nombre: 'Carmen Ruiz',
    email: 'carmen.ruiz@empresa.es',
    telefono: '',
  });

  function cambiar(e: ChangeEvent<HTMLInputElement>) {
    const { name, value } = e.target;
    setDatos((actuales) => ({ ...actuales, [name]: value }));
  }

  return (
    <section className="tarjeta">
      <h2>Contacto</h2>
      <div className="fila">
        <input name="nombre" aria-label="Nombre" value={datos.nombre} onChange={cambiar} />
        <input name="email" aria-label="Correo" value={datos.email} onChange={cambiar} />
        <input name="telefono" aria-label="Teléfono" value={datos.telefono} onChange={cambiar} />
      </div>
      <p>{datos.nombre} ({datos.email}) {datos.telefono || 'sin teléfono'}</p>
    </section>
  );
}

Los paréntesis de ({ ...actuales, [name]: value }) son necesarios. Sin ellos, las llaves serían el cuerpo de la flecha y no un objeto.

Objetos anidados

La propagación es superficial: copia solo el primer nivel. Si el dato que cambia está más abajo, hay que copiar cada nivel del camino:

interface Ficha {
  nombre: string;
  contrato: { tipo: string; horas: number };
  departamento: {
    nombre: string;
    responsable: { nombre: string };
    sede: { ciudad: string; planta: number };
  };
}

// ❌ MAL: copia es un objeto nuevo, pero su departamento y su sede son los del estado
const copia = { ...ficha };
copia.departamento.sede.planta = 3;
setFicha(copia);

// ✅ BIEN: copia cada nivel hasta llegar al dato
setFicha({
  ...ficha,
  departamento: {
    ...ficha.departamento,
    sede: { ...ficha.departamento.sede, planta: 3 },
  },
});

Dos árboles de objetos: el nuevo comparte las ramas que no cambian con el antiguo y solo crea objetos nuevos en el camino hasta planta

Figura 6. Solo se crean objetos nuevos en el camino hasta el dato. El resto se comparte, por lo que no se copia todo el árbol.

Si el código te parece largo, suele ser señal de que el estado está demasiado anidado. La sección 12 explica cómo aplanarlo. Si aun así necesitas anidar, tienes dos opciones:

Opción Cómo funciona Cuándo usarla
structuredClone(obj) Copia todo el árbol. Falla con funciones y pierde las clases propias Rara vez: rompe el reparto de ramas, y todos los objetos pasan a ser nuevos
Immer Escribes como si mutaras un borrador y crea las copias por ti Estado anidado de verdad. Redux Toolkit lo usa por dentro (módulo 20)
import { produce } from 'immer'; // npm install immer

setFicha(
  produce((borrador) => {
    borrador.departamento.sede.planta = 3; // aquí sí se puede
  }),
);

produce con un solo argumento devuelve una función, que encaja como función actualizadora. El objeto original queda intacto.

💡 Consejo: Deja que las herramientas vigilen las mutaciones. Con useState<Readonly<FichaEmpleado>>, TypeScript rechaza ficha.salario = 0 al compilar, aunque solo en el primer nivel. Y si congelas el valor inicial con Object.freeze, una mutación lanzará en ejecución TypeError: Cannot assign to read only property 'salario' of object, en lugar de fallar en silencio. oxlint también avisa: react(immutability): This value cannot be modified.

Estado que puede ser null

Si no hay ficha, no hay qué propagar. El operador && resuelve el caso de forma compacta:

const [ficha, setFicha] = useState<FichaEmpleado | null>(null);

function subir() {
  setFicha((actual) => actual && { ...actual, salario: actual.salario + 100 });
}

9. Arrays en el estado

Con los arrays la regla es la misma: nunca los modifiques, crea uno nuevo. Lo que debes memorizar es qué métodos mutan y cuáles devuelven un array nuevo.

Operación ❌ Muta ✅ Crea uno nuevo
Añadir push, unshift [...lista, nuevo], [nuevo, ...lista]
Quitar pop, shift, splice filter, slice, toSpliced
Cambiar uno lista[i] = x map, with
Insertar en medio splice toSpliced, [...a.slice(0, i), x, ...a.slice(i)]
Ordenar sort toSorted
Invertir reverse toReversed

toSorted, toReversed, toSpliced y with son de ES2023, están en todos los navegadores actuales y la plantilla de Vite ya los incluye en su lib. Se escribieron precisamente para evitar copiar primero. Sin ellos, la forma clásica es [...lista].sort(...).

Esta pantalla reúne todas las operaciones:

import { useState } from 'react';

interface Empleado {
  empId: number;
  nombre: string;
  salario: number;
}

const INICIALES: Empleado[] = [
  { empId: 110, nombre: 'Carmen Ruiz', salario: 2200 },
  { empId: 111, nombre: 'David Torres', salario: 2100 },
  { empId: 112, nombre: 'Elena Navarro', salario: 2300 },
];

export default function Plantilla() {
  const [empleados, setEmpleados] = useState(INICIALES);
  const [nombre, setNombre] = useState('');

  function alta() {
    const limpio = nombre.trim();
    if (limpio === '') return;
    const empId = Math.max(0, ...empleados.map((e) => e.empId)) + 1;
    setEmpleados([...empleados, { empId, nombre: limpio, salario: 1500 }]);
    setNombre('');
  }

  function baja(empId: number) {
    setEmpleados(empleados.filter((e) => e.empId !== empId));
  }

  function subirSalario(empId: number) {
    setEmpleados(
      empleados.map((e) =>
        e.empId === empId ? { ...e, salario: Math.round(e.salario * 1.05) } : e,
      ),
    );
  }

  function subirPuesto(indice: number) {
    if (indice === 0) return;
    setEmpleados(
      empleados
        .with(indice - 1, empleados[indice])
        .with(indice, empleados[indice - 1]),
    );
  }

  function ordenar() {
    setEmpleados(empleados.toSorted((a, b) => b.salario - a.salario));
  }

  return (
    <section className="tarjeta">
      <h2>Plantilla ({empleados.length})</h2>
      <div className="fila">
        <input
          aria-label="Nombre"
          placeholder="Nombre"
          value={nombre}
          onChange={(e) => setNombre(e.target.value)}
        />
        <button onClick={alta}>Alta</button>
        <button onClick={ordenar}>Ordenar por salario</button>
        <button onClick={() => setEmpleados(empleados.toReversed())}>Invertir</button>
      </div>
      <table>
        <thead>
          <tr><th>Id</th><th>Nombre</th><th>Salario</th><th>Acciones</th></tr>
        </thead>
        <tbody>
          {empleados.map((e, i) => (
            <tr key={e.empId}>
              <td>{e.empId}</td>
              <td>{e.nombre}</td>
              <td>{e.salario} €</td>
              <td>
                <button onClick={() => subirSalario(e.empId)}>+5 %</button>{' '}
                <button onClick={() => subirPuesto(i)}>↑</button>{' '}
                <button onClick={() => baja(e.empId)}>Baja</button>
              </td>
            </tr>
          ))}
        </tbody>
      </table>
    </section>
  );
}

Tabla Plantilla con cuatro empleados, sus salarios y botones de subir salario, subir puesto y baja

Figura 7. La plantilla tras dar un alta, subir el salario de Elena y ordenar.

Fíjate en estos detalles:

  • key={e.empId} identifica cada fila. La razón de usar un id y no el índice se explica en el módulo 06.
  • onClick={() => baja(e.empId)} crea una función para pasar el argumento. Si escribieras onClick={baja(e.empId)}, la llamarías durante el render (sección 14).
  • subirSalario usa map y solo crea un objeto nuevo para el elemento que cambia. El resto se conserva tal cual.
  • Calcular el id con Math.max está bien para este ejemplo en memoria. En una aplicación real lo asigna la base de datos.

La trampa de la copia superficial

Copiar un array no copia sus elementos. Los dos arrays apuntan a los mismos objetos:

// ❌ MAL: siguientes es un array nuevo, pero el objeto de dentro es el mismo
const siguientes = [...empleados];
siguientes[0].salario = 3000;   // también cambia empleados[0]
setEmpleados(siguientes);

Esta mutación puede pasar desapercibida porque hay un render y se ve el valor nuevo. El fallo saldrá cuando dos partes del estado compartan el objeto, cuando un memo crea que no ha cambiado o al implementar “deshacer”. La forma correcta es la de subirSalario: copiar también el elemento que cambia.

Set y Map en el estado

También sirven como estado, y un Set es muy cómodo para una selección múltiple. Pero tienen métodos que mutan (add, delete, set), y Object.is compara la referencia. Hay que crear uno nuevo:

const [marcados, setMarcados] = useState<Set<number>>(new Set());

function alternar(id: number) {
  setMarcados((actuales) => {
    const siguientes = new Set(actuales);
    if (!siguientes.delete(id)) siguientes.add(id);
    return siguientes;
  });
}

Aquí new Set(actuales) crea una copia local, y esa copia sí se puede modificar porque nadie más la conoce. Mutar lo que acabas de crear es correcto; lo que no se toca es el estado existente. Lo practicarás en el ejercicio 7.


10. Estado derivado

Hasta aquí has aprendido a guardar datos. Ahora viene lo más útil: aprender a no guardarlos.

Si un dato se puede calcular a partir de otros que ya tienes, no es estado. Es un valor derivado, y se calcula durante el render. Este buscador guarda en el estado algo que no debería:

// ❌ MAL: filtrados es una copia que hay que mantener sincronizada
const [texto, setTexto] = useState('');
const [filtrados, setFiltrados] = useState(EMPLEADOS);

function cambiarTexto(nuevo: string) {
  setTexto(nuevo);
  setFiltrados(EMPLEADOS.filter((e) => e.nombre.includes(nuevo)));
}

Funciona hasta que hay otra forma de cambiar los datos. Si añades un filtro por departamento, tendrás que recalcular en dos sitios, y si das de baja a un empleado, en tres. Cada olvido deja la lista desincronizada.

La versión correcta guarda solo lo que el usuario decide y calcula el resto:

import { useState } from 'react';

interface Empleado {
  empId: number;
  nombre: string;
  oficio: string;
  salario: number;
}

const EMPLEADOS: Empleado[] = [
  { empId: 101, nombre: 'Luis Martínez', oficio: 'DIRECTOR', salario: 4800 },
  { empId: 110, nombre: 'Carmen Ruiz', oficio: 'VENDEDOR', salario: 2200 },
  { empId: 111, nombre: 'David Torres', oficio: 'VENDEDOR', salario: 2100 },
  { empId: 112, nombre: 'Elena Navarro', oficio: 'VENDEDOR', salario: 2300 },
  { empId: 114, nombre: 'Lucía Romero', oficio: 'ADMINISTRATIVO', salario: 1700 },
  { empId: 120, nombre: 'Pablo Moreno', oficio: 'ANALISTA', salario: 3500 },
];

const normalizar = (texto: string) =>
  texto.normalize('NFD').replace(/\p{M}/gu, '').toLowerCase();

export default function Buscador() {
  // Estado: solo lo mínimo
  const [texto, setTexto] = useState('');
  const [idSeleccionado, setIdSeleccionado] = useState<number | null>(null);

  // Derivado: se recalcula en cada render
  const filtrados = EMPLEADOS.filter((e) =>
    normalizar(e.nombre + ' ' + e.oficio).includes(normalizar(texto)),
  );
  const masaSalarial = filtrados.reduce((suma, e) => suma + e.salario, 0);
  const seleccionado = EMPLEADOS.find((e) => e.empId === idSeleccionado);

  return (
    <section className="tarjeta">
      <h2>Empleados</h2>
      <div className="fila">
        <input
          aria-label="Buscar"
          placeholder="Buscar por nombre u oficio"
          value={texto}
          onChange={(e) => setTexto(e.target.value)}
        />
        <span>{filtrados.length} de {EMPLEADOS.length}, {masaSalarial} € al mes</span>
      </div>
      <table>
        <tbody>
          {filtrados.map((e) => (
            <tr key={e.empId}>
              <td>{e.nombre}</td>
              <td>{e.oficio}</td>
              <td>
                <button
                  aria-pressed={e.empId === idSeleccionado}
                  onClick={() => setIdSeleccionado(e.empId)}
                >
                  Ver
                </button>
              </td>
            </tr>
          ))}
        </tbody>
      </table>
      <p>{seleccionado ? `Ficha de ${seleccionado.nombre}: ${seleccionado.salario} €` : 'Nadie seleccionado'}</p>
    </section>
  );
}

Buscador con el texto vend, tres vendedores en la tabla y la ficha de Elena Navarro seleccionada

Figura 8. Toda la pantalla sale de dos estados: el texto y el id seleccionado. Los totales y la lista filtrada son derivados.

Tres ideas que se llevan de este ejemplo:

  • Es menos código y no hay nada que sincronizar. Aunque añadas más filtros, el dato no puede quedar desfasado, porque se recalcula siempre.
  • Se guarda el id, no el objeto. Si guardaras una copia del empleado seleccionado y luego alguien lo modificara en la lista, la ficha seguiría mostrando el dato antiguo.
  • normalizar quita tildes, de forma que “lucia” encuentra a “Lucía”. \p{M} son las marcas diacríticas que quedan separadas tras normalize('NFD').

💡 Consejo: Para decidir, hazte estas preguntas sobre cada dato. ¿No cambia nunca durante la vida del componente? No es estado. ¿Llega por props? No es estado. ¿Se puede calcular a partir de otro estado o de props? No es estado. Solo lo que queda después de esas tres preguntas merece un useState.

Quizá te preocupe recalcular en cada render. Filtrar unos cientos de elementos cuesta muy poco. Si un cálculo es de verdad costoso, existe useMemo, del módulo 14. Y aunque hoy no lo conozcas, verás código que sincroniza estados con useEffect; el módulo 09 explica por qué casi siempre sobra.

Copiar una prop en el estado

Otro error clásico de redundancia:

import { useState } from 'react';

function Etiqueta({ precio }: { precio: number }) {
  const [precioMostrado] = useState(precio); // ❌ copia inicial, y nunca se actualiza
  return <p>Espejo: {precioMostrado} € / Prop: {precio} €</p>;
}

export default function EspejoProp() {
  const [precio, setPrecio] = useState(899);
  return (
    <section className="tarjeta">
      <Etiqueta precio={precio} />
      <button onClick={() => setPrecio(precio - 50)}>Rebajar</button>
    </section>
  );
}

Tras pulsar “Rebajar”, la pantalla dice Espejo: 899 € / Prop: 849 €. El argumento de useState solo se usa en el primer render, como ya sabes. La solución es usar la prop directamente, o derivar lo que necesites de ella.

Hay un caso legítimo: querer un valor de partida que el usuario edite después, sin que le afecten los cambios posteriores del padre. Déjalo claro con el nombre, usando un prefijo como inicial:

function EditorPrecio({ precioInicial }: { precioInicial: number }) {
  const [precio, setPrecio] = useState(precioInicial);
  // ...
}

Reiniciar parte del estado al cambiar otra

Imagina un listado con búsqueda y paginación. Al buscar, hay que volver a la página 1. Lo primero que se debe intentar es hacerlo en el manejador, que es el lugar donde ocurre el cambio:

function buscar(nuevo: string) {
  setTexto(nuevo);
  setPagina(1);
}

Con esto basta en casi todos los casos. Pero si lo que cambia es una prop que llega desde fuera, el componente no tiene ningún manejador donde hacerlo. Para ese caso, la documentación de React describe un recurso menos habitual: ajustar el estado durante el render, comparando con el valor anterior. React repite el render de inmediato, antes de renderizar a los hijos y de actualizar la pantalla:

import { useState } from 'react';

function Listado({ filtro }: { filtro: string }) {
  const [pagina, setPagina] = useState(3);
  const [filtroAnterior, setFiltroAnterior] = useState(filtro);

  if (filtro !== filtroAnterior) {
    setFiltroAnterior(filtro);
    setPagina(1);
  }

  return (
    <p>
      Filtro {filtro}, página {pagina}{' '}
      <button onClick={() => setPagina(pagina + 1)}>Siguiente</button>
    </p>
  );
}

export default function AjusteEnRender() {
  const [filtro, setFiltro] = useState('VENDEDOR');
  return (
    <section className="tarjeta">
      <Listado filtro={filtro} />
      <button onClick={() => setFiltro('ANALISTA')}>Ver analistas</button>
    </section>
  );
}

La condición es imprescindible. Sin ella tendrías un bucle infinito (sección 14). Solo puedes llamar así al setter del propio componente. Y antes de usarlo, piensa si el dato se podría derivar, o si bastaría con reiniciar el componente entero con key, que se enseña en el módulo 05.


11. Elevar el estado

Cuando dos componentes necesitan el mismo dato, ninguno puede quedárselo. El estado debe subir al ancestro común más cercano y bajar por props. Se llama elevar el estado (lifting state up).

Partimos de un panel de departamentos con este requisito: solo puede estar abierta una sección a la vez. Si cada Seccion tiene su propio abierta, no hay forma de saber qué hacen las demás.

Antes, cada sección tiene su propio estado abierta y no se comunican. Después, el padre guarda abierto y pasa estaAbierta y onAbrir a cada hija

Figura 9. Al elevar, el estado baja como dato y los cambios suben como llamadas a funciones.

Se hace en tres pasos:

  1. Quita el estado del hijo y conviértelo en una prop (estaAbierta).
  2. Pasa una función para que el hijo pueda pedir el cambio (onAbrir).
  3. Añade el estado al padre, que decide cómo se calcula la prop de cada hijo.
import { useState, type ReactNode } from 'react';

interface PropsSeccion {
  titulo: string;
  estaAbierta: boolean;
  onAbrir: () => void;
  children: ReactNode;
}

function Seccion({ titulo, estaAbierta, onAbrir, children }: PropsSeccion) {
  return (
    <div>
      <button aria-expanded={estaAbierta} onClick={onAbrir}>
        {estaAbierta ? '▾' : '▸'} {titulo}
      </button>
      {estaAbierta && <p>{children}</p>}
    </div>
  );
}

type Departamento = 'DIRECCION' | 'VENTAS' | 'INFORMATICA';

export default function Departamentos() {
  const [abierto, setAbierto] = useState<Departamento | null>('VENTAS');

  // Pulsar en el abierto lo cierra
  function alternar(departamento: Departamento) {
    setAbierto((actual) => (actual === departamento ? null : departamento));
  }

  return (
    <section className="tarjeta">
      <h2>Departamentos</h2>
      <Seccion
        titulo="Dirección"
        estaAbierta={abierto === 'DIRECCION'}
        onAbrir={() => alternar('DIRECCION')}
      >
        Ana García.
      </Seccion>
      <Seccion
        titulo="Ventas"
        estaAbierta={abierto === 'VENTAS'}
        onAbrir={() => alternar('VENTAS')}
      >
        Luis Martínez, Carmen Ruiz, David Torres, Elena Navarro, Sergio Jiménez y Lucía Romero.
      </Seccion>
      <Seccion
        titulo="Informática"
        estaAbierta={abierto === 'INFORMATICA'}
        onAbrir={() => alternar('INFORMATICA')}
      >
        Marta Sánchez y Pablo Moreno.
      </Seccion>
    </section>
  );
}

Acordeón de departamentos con Ventas desplegado mostrando sus empleados y las otras dos secciones plegadas

Figura 10. El padre guarda cuál está abierto. Es imposible tener dos abiertos, porque solo hay un valor.

Observa cómo cada hijo ya no decide, solo pide. Ahora el padre controla el comportamiento, de modo que cambiar la regla, por ejemplo para permitir varias abiertas, solo afecta a un sitio.

📘 Concepto: Un componente es controlado cuando sus datos importantes llegan por props, y no controlado cuando los guarda en su propio estado. Seccion empezó siendo no controlado y ahora es controlado. El no controlado es más cómodo de usar; el controlado, más flexible. Es la misma distinción que verás en los formularios del módulo 08.

Hermanos que se comunican

Dos hermanos no pueden hablarse directamente. La comunicación pasa siempre por el padre. Aquí el buscador, la tabla y el resumen son tres componentes y comparten el mismo texto:

import { useState } from 'react';

interface Producto {
  productoId: number;
  nombre: string;
  precio: number;
  stock: number;
}

const PRODUCTOS: Producto[] = [
  { productoId: 1, nombre: 'Portátil UltraBook 14', precio: 899, stock: 25 },
  { productoId: 2, nombre: 'Portátil Gaming 16', precio: 1499, stock: 10 },
  { productoId: 6, nombre: 'Ratón inalámbrico', precio: 19.9, stock: 200 },
  { productoId: 8, nombre: 'Pack teclado y ratón', precio: 34.5, stock: 0 },
  { productoId: 9, nombre: 'Monitor 24 FHD', precio: 139, stock: 35 },
];

interface PropsFiltro {
  texto: string;
  soloDisponibles: boolean;
  onCambiarTexto: (texto: string) => void;
  onCambiarDisponibles: (valor: boolean) => void;
}

function Filtro({ texto, soloDisponibles, onCambiarTexto, onCambiarDisponibles }: PropsFiltro) {
  return (
    <div className="fila">
      <input
        aria-label="Buscar producto"
        placeholder="Buscar"
        value={texto}
        onChange={(e) => onCambiarTexto(e.target.value)}
      />
      <label>
        <input
          type="checkbox"
          checked={soloDisponibles}
          onChange={(e) => onCambiarDisponibles(e.target.checked)}
        />{' '}
        Solo con stock
      </label>
    </div>
  );
}

function Tabla({ productos }: { productos: Producto[] }) {
  return (
    <table>
      <tbody>
        {productos.map((p) => (
          <tr key={p.productoId}>
            <td>{p.nombre}</td>
            <td>{p.precio} €</td>
            <td>{p.stock}</td>
          </tr>
        ))}
      </tbody>
    </table>
  );
}

function Resumen({ productos }: { productos: Producto[] }) {
  const valor = productos.reduce((suma, p) => suma + p.precio * p.stock, 0);
  return <p>{productos.length} productos, {valor.toFixed(2)} € en almacén</p>;
}

export default function Catalogo() {
  const [texto, setTexto] = useState('');
  const [soloDisponibles, setSoloDisponibles] = useState(false);

  const visibles = PRODUCTOS.filter(
    (p) =>
      p.nombre.toLowerCase().includes(texto.toLowerCase()) &&
      (!soloDisponibles || p.stock > 0),
  );

  return (
    <section className="tarjeta">
      <h2>Catálogo</h2>
      <Filtro
        texto={texto}
        soloDisponibles={soloDisponibles}
        onCambiarTexto={setTexto}
        onCambiarDisponibles={setSoloDisponibles}
      />
      <Tabla productos={visibles} />
      <Resumen productos={visibles} />
    </section>
  );
}

Fíjate en que Tabla y Resumen no saben nada de la búsqueda. Solo reciben una lista. Es la mejor señal de un buen diseño.

¿Dónde debe vivir cada estado?

Para cada dato, sigue estos pasos:

  1. Localiza todos los componentes que lo usan.
  2. Busca su ancestro común más cercano.
  3. Pon el estado ahí, o incluso más arriba si tiene sentido.

📘 Concepto: Cada dato debe tener una única fuente de verdad: un solo componente que lo posee. Los demás lo reciben por props. Si un dato aparece en dos estados, tarde o temprano se contradicen.

No eleves todo por si acaso. El estado debe vivir tan abajo como sea posible: un menú desplegable no le importa a nadie más, y si lo subes, cada apertura renderizará medio árbol. Cuando elevar obliga a pasar props a través de muchos componentes que no las usan, el módulo 11 presenta Context como alternativa. Para el estado realmente global está el módulo 20.


12. Cómo estructurar el estado

Dos formas de guardar lo mismo pueden comportarse muy distinto. Un buen estado hace imposibles los errores; uno malo te obliga a evitarlos con disciplina. Estos son los cinco principios.

1. Agrupa lo que cambia junto

Si dos valores cambian siempre a la vez, un objeto evita olvidarse de uno:

// ❌ Dos estados que siempre van juntos
const [ancho, setAncho] = useState(800);
const [alto, setAlto] = useState(600);

// ✅ Un objeto
const [tamano, setTamano] = useState({ ancho: 800, alto: 600 });

Al contrario, no metas todo en un único objeto gigante: si los valores cambian por separado, varios useState son más claros.

2. Evita las contradicciones

Tres booleanos permiten ocho combinaciones, y aquí solo cuatro tienen sentido:

// ❌ ¿Qué significa enviando=true y error=true a la vez?
const [enviando, setEnviando] = useState(false);
const [enviado, setEnviado] = useState(false);
const [error, setError] = useState<string | null>(null);

Sustitúyelos por un único estado. Con una unión discriminada de TypeScript, además, cada fase lleva solo los datos que le corresponden:

import { useState } from 'react';

type Envio =
  | { fase: 'editando' }
  | { fase: 'enviando' }
  | { fase: 'enviado'; numero: number }
  | { fase: 'error'; mensaje: string };

const esperar = (ms: number) => new Promise((resolver) => setTimeout(resolver, ms));

// Simula una llamada a la API
async function enviarAviso(texto: string): Promise<number> {
  await esperar(600);
  if (texto.toLowerCase().includes('error')) {
    throw new Error('El servidor ha rechazado el aviso');
  }
  return 5025;
}

export default function FormularioAviso() {
  const [texto, setTexto] = useState('');
  const [envio, setEnvio] = useState<Envio>({ fase: 'editando' });

  async function enviar() {
    setEnvio({ fase: 'enviando' });
    try {
      const numero = await enviarAviso(texto);
      setEnvio({ fase: 'enviado', numero });
    } catch (error) {
      setEnvio({ fase: 'error', mensaje: (error as Error).message });
    }
  }

  if (envio.fase === 'enviado') {
    return <p className="tarjeta">Aviso registrado con el número {envio.numero}.</p>;
  }

  const enviando = envio.fase === 'enviando'; // derivado

  return (
    <section className="tarjeta">
      <h2>Nuevo aviso</h2>
      <div className="fila">
        <input
          aria-label="Texto del aviso"
          value={texto}
          disabled={enviando}
          onChange={(e) => setTexto(e.target.value)}
        />
        <button disabled={enviando || texto.trim() === ''} onClick={enviar}>
          {enviando ? 'Enviando...' : 'Enviar'}
        </button>
      </div>
      {envio.fase === 'error' && <p role="alert">{envio.mensaje}</p>}
    </section>
  );
}

Formulario Nuevo aviso con un texto escrito y debajo el mensaje de error El servidor ha rechazado el aviso

Figura 11. La fase error es la única que tiene mensaje. TypeScript no te deja leerlo en las demás.

envio.numero solo compila después de comprobar que fase es 'enviado'. No es posible llegar a un estado “enviando con error”, porque no se puede escribir. Las Actions del módulo 16 resuelven este mismo patrón con ayuda de React.

3. Evita la redundancia

Ya lo viste en la sección 10. Si se puede calcular, no lo guardes:

// ❌ nombreCompleto puede contradecir a los otros dos
const [nombre, setNombre] = useState('Ana');
const [apellidos, setApellidos] = useState('García López');
const [nombreCompleto, setNombreCompleto] = useState('Ana García López');

// ✅ Derivado
const nombreCompleto = `${nombre} ${apellidos}`;

4. Evita la duplicación

El mismo dato en dos sitios acaba desincronizado. El ejemplo típico es la selección:

// ❌ seleccionado es una copia de un elemento de empleados
const [empleados, setEmpleados] = useState(INICIALES);
const [seleccionado, setSeleccionado] = useState<Empleado | null>(null);

// ✅ Guarda el id y busca el objeto
const [idSeleccionado, setIdSeleccionado] = useState<number | null>(null);
const seleccionado = empleados.find((e) => e.empId === idSeleccionado);

Con la versión buena, si modificas el salario en empleados, la ficha se actualiza sola. Si das de baja al empleado, find devuelve undefined y la ficha desaparece sin más código.

5. Evita el anidamiento profundo

Un árbol anidado es natural, pero actualizar un nodo profundo obliga a copiar todo el camino. La alternativa es guardar los datos planos (normalizados), como en una base de datos: cada elemento tiene su id y referencia a los demás por id. Es lo que hace la propia API EMPRESA con jefeId:

interface Nodo {
  empId: number;
  nombre: string;
  subordinados: number[];    // ids, no objetos
}

const ORGANIGRAMA: Record<number, Nodo> = {
  100: { empId: 100, nombre: 'Ana García', subordinados: [101, 102] },
  101: { empId: 101, nombre: 'Luis Martínez', subordinados: [110, 111] },
  102: { empId: 102, nombre: 'Marta Sánchez', subordinados: [] },
  110: { empId: 110, nombre: 'Carmen Ruiz', subordinados: [] },
  111: { empId: 111, nombre: 'David Torres', subordinados: [] },
};

// Quitar a alguien solo toca dos entradas, no todo el árbol
function quitar(organigrama: Record<number, Nodo>, jefeId: number, empId: number) {
  const { [empId]: _eliminado, ...resto } = organigrama;
  const jefe = organigrama[jefeId];
  return {
    ...resto,
    [jefeId]: { ...jefe, subordinados: jefe.subordinados.filter((id) => id !== empId) },
  };
}

Con noUnusedLocals, TypeScript permite variables sin usar en una desestructuración con resto. El guion bajo indica que descartamos ese valor a propósito.

¿Uno o varios useState?

Situación Elección
Valores independientes Varios useState
Valores que cambian siempre juntos Un objeto
Varias fases excluyentes Una unión discriminada
Muchas transiciones entre estados complejos useReducer, del módulo 11
Datos que vienen del servidor TanStack Query, del módulo 19

13. Caso completo: pedido de EMPRESA

Unimos lo aprendido en una pantalla para preparar un pedido. Antes de escribir código, decidimos cuál es el estado mínimo:

Dato ¿Estado? Motivo
Catálogo No No cambia en esta pantalla
Cantidad de cada producto Sí La decide el usuario
Código de descuento Sí Lo escribe el usuario
Porcentaje de descuento No Se deduce del código
Líneas, subtotal, IVA y total No Se calculan
¿Se puede sumar otra unidad? No Se deduce de cantidad y stock

Solo dos estados:

import { useState } from 'react';

interface Producto {
  productoId: number;
  nombre: string;
  precio: number;
  stock: number;
}

const CATALOGO: Producto[] = [
  { productoId: 1, nombre: 'Portátil UltraBook 14', precio: 899, stock: 25 },
  { productoId: 6, nombre: 'Ratón inalámbrico', precio: 19.9, stock: 200 },
  { productoId: 7, nombre: 'Teclado mecánico', precio: 79.9, stock: 60 },
  { productoId: 8, nombre: 'Pack teclado y ratón', precio: 34.5, stock: 0 },
  { productoId: 9, nombre: 'Monitor 24 FHD', precio: 139, stock: 35 },
];

const IVA = 0.21;
const CUPONES: Record<string, number> = { EMPRESA10: 0.1, CLIENTE5: 0.05 };

const euros = new Intl.NumberFormat('es-ES', { style: 'currency', currency: 'EUR' });

type Cantidades = Record<number, number>;

interface PropsSelector {
  producto: Producto;
  cantidad: number;
  onCambiar: (diferencia: number) => void;
}

function SelectorCantidad({ producto, cantidad, onCambiar }: PropsSelector) {
  return (
    <tr>
      <td>{producto.nombre}</td>
      <td>{euros.format(producto.precio)}</td>
      <td>
        <button
          aria-label={`Quitar ${producto.nombre}`}
          disabled={cantidad === 0}
          onClick={() => onCambiar(-1)}
        >
          −
        </button>{' '}
        {cantidad}{' '}
        <button
          aria-label={`Añadir ${producto.nombre}`}
          disabled={cantidad >= producto.stock}
          onClick={() => onCambiar(1)}
        >
          +
        </button>
      </td>
    </tr>
  );
}

export default function Pedido() {
  const [cantidades, setCantidades] = useState<Cantidades>({});
  const [cupon, setCupon] = useState('');

  function cambiarCantidad(productoId: number, diferencia: number) {
    setCantidades((actuales) => ({
      ...actuales,
      [productoId]: (actuales[productoId] ?? 0) + diferencia,
    }));
  }

  // Todo lo que sigue es derivado
  const lineas = CATALOGO.flatMap((producto) => {
    const cantidad = cantidades[producto.productoId] ?? 0;
    return cantidad > 0 ? [{ producto, cantidad, importe: cantidad * producto.precio }] : [];
  });
  const unidades = lineas.reduce((suma, l) => suma + l.cantidad, 0);
  const subtotal = lineas.reduce((suma, l) => suma + l.importe, 0);
  const porcentaje = CUPONES[cupon.trim().toUpperCase()] ?? 0;
  const descuento = subtotal * porcentaje;
  const base = subtotal - descuento;
  const cuotaIva = base * IVA;
  const total = base + cuotaIva;

  return (
    <section className="tarjeta">
      <h2>Nuevo pedido</h2>
      <table>
        <tbody>
          {CATALOGO.map((p) => (
            <SelectorCantidad
              key={p.productoId}
              producto={p}
              cantidad={cantidades[p.productoId] ?? 0}
              onCambiar={(diferencia) => cambiarCantidad(p.productoId, diferencia)}
            />
          ))}
        </tbody>
      </table>
      <div className="fila" style={{ marginTop: '0.75rem' }}>
        <input
          aria-label="Cupón"
          placeholder="Cupón"
          value={cupon}
          onChange={(e) => setCupon(e.target.value)}
        />
        {porcentaje > 0 && <span>Descuento del {porcentaje * 100} %</span>}
      </div>
      <p>Subtotal ({unidades} uds.): {euros.format(subtotal)}</p>
      {descuento > 0 && <p>Descuento: −{euros.format(descuento)}</p>}
      <p>IVA: {euros.format(cuotaIva)}</p>
      <p><strong>Total: {euros.format(total)}</strong></p>
      <div className="fila">
        <button onClick={() => { setCantidades({}); setCupon(''); }}>Vaciar</button>
      </div>
    </section>
  );
}

Pedido con cantidades de portátil, ratón y monitor, cupón aplicado, subtotal, descuento, IVA y total

Figura 12. Un portátil, dos ratones y un monitor con el cupón EMPRESA10. El botón “+” del pack aparece deshabilitado porque no tiene stock.

Decisiones que merece la pena revisar:

  • cantidades es un objeto indexado por id, no un array de líneas. Cambiar una cantidad es copiar y sustituir una clave, sin recorrer nada.
  • cambiarCantidad usa la función actualizadora, así que dos clics muy seguidos cuentan dos.
  • SelectorCantidad no guarda nada. Es controlado: recibe la cantidad y avisa del cambio. Por eso “Vaciar” funciona con una sola línea. Si cada fila tuviera su useState, el padre no podría reiniciarlas.
  • flatMap permite filtrar y transformar a la vez: devolver [] descarta el elemento.
  • Los límites se expresan en disabled, derivado de cantidad y stock. No hay un estado “puede sumar”.
  • Los redondeos de este ejemplo son solo de presentación. Un sistema de facturación real trabaja con céntimos enteros o con decimales exactos en el servidor.

14. Depuración y errores frecuentes

La herramienta principal es React DevTools (módulo 01). En la pestaña Components, selecciona un componente y mira su sección hooks: verás cada State en orden, con su valor actual, y podrás editarlo para probar la interfaz sin tocar código. Activa también “Highlight updates when components render” para ver quién se vuelve a renderizar.

Para el resto, un console.log al principio del componente te dice cuándo se renderiza y con qué valores.

Síntoma Causa Solución
La pantalla no cambia, pero la consola sí Variable normal en lugar de estado useState
La pantalla no cambia al actualizar un objeto o array Mutación con la misma referencia Crear un objeto o array nuevo
Un cambio aparece tarde, al pulsar otra cosa Mutación seguida de un render ajeno Crear un objeto nuevo
El valor leído tras el setter es el anterior Instantánea Guardar el valor en una variable antes
Varios set(x + 1) solo suman uno Todos ven la misma instantánea Función actualizadora
El estado no sigue a una prop El valor inicial solo se usa una vez Usar la prop directamente
El estado se pierde sin motivo El componente se desmonta Ver el módulo 05
Hay dos datos que no coinciden Estado duplicado o redundante Derivar uno de ellos
Se suman textos: '10' + 1 = '101' e.target.value siempre es texto e.target.valueAsNumber o Number(...)

Too many re-renders

Error: Too many re-renders. React limits the number of renders to prevent an infinite loop.

Aparece cuando llamas a un setter durante el render. El setter provoca un render, ese render llama al setter, y así sin fin. La causa más frecuente es escribir paréntesis de más en un manejador:

// ❌ MAL: llama a setAbierto AHORA, durante el render
<button onClick={setAbierto(true)}>Abrir</button>

// ✅ BIEN: pasa una función que se llamará al pulsar
<button onClick={() => setAbierto(true)}>Abrir</button>

TypeScript suele detectarlo también en este caso: setAbierto(true) devuelve void, y onClick no acepta ese tipo.

Actualizar a otro componente mientras se renderiza

Si llamas al setter de un padre durante el render de un hijo, recibes este aviso:

Cannot update a component (`Padre`) while rendering a different component (`Hijo`).

Ese setter debe ir en un manejador de eventos. El render solo calcula.

Guardar una función en el estado

useState trata las funciones de forma especial, en dos sitios. Esto no guarda porNombre, sino que lo ejecuta:

const [comparar, setComparar] = useState(porNombre);    // ❌ inicialización perezosa
setComparar(porSalario);                                // ❌ función actualizadora

Envuélvela en otra función, para que React llame a la exterior y guarde la interior:

const [comparar, setComparar] = useState(() => porNombre);  // ✅
setComparar(() => porSalario);                              // ✅

Aun así, es más claro guardar un dato sencillo, como 'nombre' o 'salario', y elegir la función al renderizar. Es lo que hace el ejercicio 6.

Pasar de undefined a un valor en un campo

const [nota, setNota] = useState<string>();   // ❌ undefined al principio
<input value={nota} onChange={(e) => setNota(e.target.value)} />

Al escribir aparece este aviso, porque el campo pasa de no tener valor a tenerlo:

A component is changing an uncontrolled input to be controlled.

Inicializa siempre con ''. El módulo 08 explica el motivo.


15. Ejercicios

Todos los ejercicios sustituyen a src/App.tsx y usan el CSS de la sección 1.

Ejercicio 1: Control de existencias

Crea una tarjeta con un producto (Teclado mecánico, stock 60) y una cantidad a retirar que empieza en 1. Debe tener botones “−” y “+” que no permitan bajar de 1 ni subir por encima de lo que hay en stock, y un botón “Retirar” que reste la cantidad al stock y devuelva la cantidad a 1, o al stock restante si es menor. Cuando no quede stock, muestra “Agotado”.

Ver solución
import { useState } from 'react';

export default function Existencias() {
  const [stock, setStock] = useState(60);
  const [cantidad, setCantidad] = useState(1);

  const agotado = stock === 0;

  function retirar() {
    const restante = stock - cantidad;
    setStock(restante);
    setCantidad(Math.min(1, restante));
  }

  return (
    <section className="tarjeta">
      <h2>Teclado mecánico</h2>
      <p>{agotado ? 'Agotado' : `Stock: ${stock}`}</p>
      <div className="fila">
        <button disabled={cantidad <= 1} onClick={() => setCantidad((c) => c - 1)}>−</button>
        <span>{cantidad}</span>
        <button disabled={cantidad >= stock} onClick={() => setCantidad((c) => c + 1)}>+</button>
        <button disabled={agotado} onClick={retirar}>Retirar</button>
      </div>
    </section>
  );
}

agotado y los dos disabled son derivados. En retirar calculamos restante en una variable porque lo usamos dos veces y stock no cambiará hasta el siguiente render.

Ejercicio 2: Visor de productos

Muestra los productos de uno en uno, con botones “Anterior” y “Siguiente” que den la vuelta al llegar a un extremo, y el texto “2 de 5”. Añade un botón “Ver detalles / Ocultar detalles” que muestre el stock. Guarda el mínimo estado posible.

Ver solución
import { useState } from 'react';

const PRODUCTOS = [
  { nombre: 'Portátil UltraBook 14', precio: 899, stock: 25 },
  { nombre: 'Portátil Gaming 16', precio: 1499, stock: 10 },
  { nombre: 'Torre Oficina i5', precio: 649, stock: 15 },
  { nombre: 'Teclado mecánico', precio: 79.9, stock: 60 },
  { nombre: 'Monitor 27 4K', precio: 329, stock: 12 },
];

export default function Visor() {
  const [indice, setIndice] = useState(0);
  const [conDetalles, setConDetalles] = useState(false);

  const total = PRODUCTOS.length;
  const producto = PRODUCTOS[indice];

  return (
    <section className="tarjeta">
      <h2>{producto.nombre}</h2>
      <p>{producto.precio} € ({indice + 1} de {total})</p>
      {conDetalles && <p>Stock: {producto.stock}</p>}
      <div className="fila">
        <button onClick={() => setIndice((i) => (i - 1 + total) % total)}>Anterior</button>
        <button onClick={() => setIndice((i) => (i + 1) % total)}>Siguiente</button>
        <button onClick={() => setConDetalles((v) => !v)}>
          {conDetalles ? 'Ocultar detalles' : 'Ver detalles'}
        </button>
      </div>
    </section>
  );
}

Solo hay un índice y un booleano. El producto y el texto “2 de 5” son derivados. El operador % da la vuelta; sumar total antes evita que un resto negativo salga de la lista.

Ejercicio 3: Caza los fallos

Este componente tiene cuatro fallos. Encuéntralos, explícalos y corrígelos sin añadir estados nuevos.

const [empleados, setEmpleados] = useState([{ id: 110, nombre: 'Carmen', ventas: 0 }]);
const [total, setTotal] = useState(0);

function vender(id) {
  const e = empleados.find((x) => x.id === id);
  e.ventas++;
  setEmpleados(empleados);
  setTotal(total + 1);
  setTotal(total + 1);
}

<button onClick={vender(110)}>Dos ventas</button>
Ver solución
  1. e.ventas++ muta un objeto del estado.
  2. setEmpleados(empleados) pasa la misma referencia, de modo que no hay render.
  3. total es redundante, porque se puede derivar. Además, los dos setTotal(total + 1) solo suman uno.
  4. onClick={vender(110)} llama a la función durante el render, lo que acaba en Too many re-renders.

Falta también el tipo de id, que TypeScript exigiría.

import { useState } from 'react';

export default function Ventas() {
  const [empleados, setEmpleados] = useState([{ id: 110, nombre: 'Carmen', ventas: 0 }]);

  const total = empleados.reduce((suma, e) => suma + e.ventas, 0);

  function vender(id: number) {
    setEmpleados((actuales) =>
      actuales.map((e) => (e.id === id ? { ...e, ventas: e.ventas + 2 } : e)),
    );
  }

  return (
    <section className="tarjeta">
      <p>{empleados[0].nombre}: {empleados[0].ventas}. Total: {total}</p>
      <button onClick={() => vender(110)}>Dos ventas</button>
    </section>
  );
}

Ejercicio 4: Ficha con datos anidados

Parte de este estado: { nombre: 'Pablo Moreno', puesto: { oficio: 'ANALISTA', salario: 3500 }, direccion: { ciudad: 'Madrid', cp: '28001' } }. Haz un campo para editar la ciudad y un botón que suba el salario un 10 %. Comprueba que puesto y direccion cambian de referencia solo cuando corresponde, mostrando en consola si el objeto ha cambiado.

Ver solución
import { useState } from 'react';

const INICIAL = {
  nombre: 'Pablo Moreno',
  puesto: { oficio: 'ANALISTA', salario: 3500 },
  direccion: { ciudad: 'Madrid', cp: '28001' },
};

export default function FichaAnidada() {
  const [ficha, setFicha] = useState(INICIAL);

  function subirSalario() {
    const siguiente = {
      ...ficha,
      puesto: { ...ficha.puesto, salario: Math.round(ficha.puesto.salario * 1.1) },
    };
    console.log('puesto cambia:', siguiente.puesto !== ficha.puesto);
    console.log('direccion cambia:', siguiente.direccion !== ficha.direccion);
    setFicha(siguiente);
  }

  function cambiarCiudad(ciudad: string) {
    setFicha({ ...ficha, direccion: { ...ficha.direccion, ciudad } });
  }

  return (
    <section className="tarjeta">
      <h2>{ficha.nombre}</h2>
      <p>{ficha.puesto.oficio}: {ficha.puesto.salario} €</p>
      <div className="fila">
        <input
          aria-label="Ciudad"
          value={ficha.direccion.ciudad}
          onChange={(e) => cambiarCiudad(e.target.value)}
        />
        <button onClick={subirSalario}>Subir 10 %</button>
      </div>
      <p>{ficha.direccion.cp} {ficha.direccion.ciudad}</p>
    </section>
  );
}

Al subir el salario, la consola muestra:

puesto cambia: true
direccion cambia: false

direccion conserva la misma referencia, porque no ha cambiado. Esto es la figura 6 en la práctica.

Ejercicio 5: Tareas de un proyecto

Haz una lista de tareas del proyecto “Migración a la nube”: añadir con un campo de texto, marcar como hecha con una casilla, borrar, y un contador “2 de 5 pendientes”. Añade un botón “Borrar hechas” que solo se active si hay alguna hecha. No aceptes textos vacíos.

Ver solución
import { useState } from 'react';

interface Tarea {
  id: number;
  texto: string;
  hecha: boolean;
}

export default function Tareas() {
  const [tareas, setTareas] = useState<Tarea[]>([
    { id: 1, texto: 'Inventariar servidores', hecha: true },
    { id: 2, texto: 'Migrar la base de datos', hecha: false },
  ]);
  const [texto, setTexto] = useState('');

  const pendientes = tareas.filter((t) => !t.hecha).length;
  const hayHechas = pendientes < tareas.length;

  function anadir() {
    const limpio = texto.trim();
    if (limpio === '') return;
    const id = Math.max(0, ...tareas.map((t) => t.id)) + 1;
    setTareas([...tareas, { id, texto: limpio, hecha: false }]);
    setTexto('');
  }

  function alternar(id: number) {
    setTareas(tareas.map((t) => (t.id === id ? { ...t, hecha: !t.hecha } : t)));
  }

  return (
    <section className="tarjeta">
      <h2>Migración a la nube</h2>
      <p>{pendientes} de {tareas.length} pendientes</p>
      <div className="fila">
        <input
          aria-label="Nueva tarea"
          value={texto}
          onChange={(e) => setTexto(e.target.value)}
        />
        <button onClick={anadir}>Añadir</button>
        <button disabled={!hayHechas} onClick={() => setTareas(tareas.filter((t) => !t.hecha))}>
          Borrar hechas
        </button>
      </div>
      <ul>
        {tareas.map((t) => (
          <li key={t.id}>
            <label>
              <input type="checkbox" checked={t.hecha} onChange={() => alternar(t.id)} />{' '}
              {t.hecha ? <s>{t.texto}</s> : t.texto}
            </label>{' '}
            <button onClick={() => setTareas(tareas.filter((x) => x.id !== t.id))}>Borrar</button>
          </li>
        ))}
      </ul>
    </section>
  );
}

Ejercicio 6: Tabla ordenable

Muestra una tabla de empleados con cabeceras pulsables para Nombre y Salario. Al pulsar una cabecera se ordena por esa columna en ascendente; si ya lo estaba, se invierte el sentido. Marca la columna activa con ▲ o ▼. No modifiques el array original ni guardes la lista ordenada en el estado.

Ver solución
import { useState } from 'react';

interface Empleado {
  empId: number;
  nombre: string;
  salario: number;
}

const EMPLEADOS: Empleado[] = [
  { empId: 112, nombre: 'Elena Navarro', salario: 2300 },
  { empId: 110, nombre: 'Carmen Ruiz', salario: 2200 },
  { empId: 120, nombre: 'Pablo Moreno', salario: 3500 },
  { empId: 114, nombre: 'Lucía Romero', salario: 1700 },
];

type Columna = 'nombre' | 'salario';

interface Orden {
  columna: Columna;
  ascendente: boolean;
}

function comparar(a: Empleado, b: Empleado, columna: Columna) {
  return columna === 'nombre'
    ? a.nombre.localeCompare(b.nombre, 'es')
    : a.salario - b.salario;
}

export default function TablaOrdenable() {
  const [orden, setOrden] = useState<Orden>({ columna: 'nombre', ascendente: true });

  const ordenados = EMPLEADOS.toSorted((a, b) => {
    const resultado = comparar(a, b, orden.columna);
    return orden.ascendente ? resultado : -resultado;
  });

  function ordenarPor(columna: Columna) {
    setOrden((actual) => ({
      columna,
      ascendente: actual.columna === columna ? !actual.ascendente : true,
    }));
  }

  function marca(columna: Columna) {
    if (orden.columna !== columna) return '';
    return orden.ascendente ? ' ▲' : ' ▼';
  }

  return (
    <section className="tarjeta">
      <table>
        <thead>
          <tr>
            <th><button onClick={() => ordenarPor('nombre')}>Nombre{marca('nombre')}</button></th>
            <th><button onClick={() => ordenarPor('salario')}>Salario{marca('salario')}</button></th>
          </tr>
        </thead>
        <tbody>
          {ordenados.map((e) => (
            <tr key={e.empId}><td>{e.nombre}</td><td>{e.salario} €</td></tr>
          ))}
        </tbody>
      </table>
    </section>
  );
}

orden.columna y orden.ascendente cambian juntos, por eso van en un objeto. La lista ordenada es derivada. localeCompare con 'es' ordena bien las tildes.

Ejercicio 7: Selección múltiple

Muestra los pedidos en una lista con casillas, una casilla “Seleccionar todos” y el importe total de lo seleccionado. Usa un Set de ids en el estado. La casilla general debe aparecer marcada solo si están todos.

Ver solución
import { useState } from 'react';

const PEDIDOS = [
  { pedidoId: 5001, cliente: 'Talleres Martín S.L.', importe: 1499 },
  { pedidoId: 5002, cliente: 'Papelería Sol', importe: 320 },
  { pedidoId: 5003, cliente: 'Hotel Mirador', importe: 2150 },
];

export default function SeleccionPedidos() {
  const [marcados, setMarcados] = useState<Set<number>>(new Set());

  const todos = marcados.size === PEDIDOS.length;
  const total = PEDIDOS
    .filter((p) => marcados.has(p.pedidoId))
    .reduce((suma, p) => suma + p.importe, 0);

  function alternar(id: number) {
    setMarcados((actuales) => {
      const siguientes = new Set(actuales);
      if (!siguientes.delete(id)) siguientes.add(id);
      return siguientes;
    });
  }

  function alternarTodos() {
    setMarcados(todos ? new Set() : new Set(PEDIDOS.map((p) => p.pedidoId)));
  }

  return (
    <section className="tarjeta">
      <h2>Pedidos</h2>
      <label>
        <input type="checkbox" checked={todos} onChange={alternarTodos} /> Seleccionar todos
      </label>
      <ul>
        {PEDIDOS.map((p) => (
          <li key={p.pedidoId}>
            <label>
              <input
                type="checkbox"
                checked={marcados.has(p.pedidoId)}
                onChange={() => alternar(p.pedidoId)}
              />{' '}
              {p.pedidoId} {p.cliente}: {p.importe} €
            </label>
          </li>
        ))}
      </ul>
      <p>{marcados.size} seleccionados, {total} €</p>
    </section>
  );
}

Los importes y los clientes de este ejercicio son inventados, para simplificar. todos y total son derivados, y por eso nunca pueden contradecir a la selección.

Ejercicio 8: Conversor con una sola fuente de verdad

Haz dos campos, Euros y Dólares, con un componente CampoMoneda reutilizable. Al escribir en uno, el otro se actualiza. Usa un cambio fijo de 1,10 dólares por euro, solo como ejemplo. Debes guardar lo que ha escrito el usuario, no los dos importes, para que no se pierda precisión ni se reescriba el campo que está editando.

Ver solución
import { useState } from 'react';

const CAMBIO = 1.1;

type Moneda = 'eur' | 'usd';

interface PropsCampo {
  etiqueta: string;
  valor: string;
  onCambiar: (texto: string) => void;
}

function CampoMoneda({ etiqueta, valor, onCambiar }: PropsCampo) {
  return (
    <label className="fila">
      {etiqueta}
      <input value={valor} onChange={(e) => onCambiar(e.target.value)} />
    </label>
  );
}

function convertir(texto: string, factor: number) {
  const numero = Number(texto.replace(',', '.'));
  if (texto.trim() === '' || Number.isNaN(numero)) return '';
  return (numero * factor).toFixed(2);
}

export default function Conversor() {
  const [entrada, setEntrada] = useState<{ moneda: Moneda; texto: string }>({
    moneda: 'eur',
    texto: '100',
  });

  const euros = entrada.moneda === 'eur' ? entrada.texto : convertir(entrada.texto, 1 / CAMBIO);
  const dolares = entrada.moneda === 'usd' ? entrada.texto : convertir(entrada.texto, CAMBIO);

  return (
    <section className="tarjeta">
      <CampoMoneda
        etiqueta="Euros"
        valor={euros}
        onCambiar={(texto) => setEntrada({ moneda: 'eur', texto })}
      />
      <CampoMoneda
        etiqueta="Dólares"
        valor={dolares}
        onCambiar={(texto) => setEntrada({ moneda: 'usd', texto })}
      />
    </section>
  );
}

El estado está en el padre, y cada campo es controlado. Solo se guarda un dato: qué escribió el usuario y en qué moneda. Ambos valores visibles son derivados.

Ejercicio 9: Rediseña el estado

Este componente funciona, pero su estado tiene redundancia, duplicación y contradicciones. Rediséñalo y escribe el componente completo, con botones para sumar una unidad a la línea seleccionada y para confirmar el pedido (simula 300 ms de espera).

const [lineas, setLineas] = useState(LINEAS);
const [total, setTotal] = useState(calcularTotal(LINEAS));
const [lineaSeleccionada, setLineaSeleccionada] = useState(LINEAS[0]);
const [confirmando, setConfirmando] = useState(false);
const [confirmado, setConfirmado] = useState(false);
Ver solución
  • total es redundante: se deriva de lineas.
  • lineaSeleccionada es una duplicación: se guarda el numLinea.
  • confirmando y confirmado permiten una contradicción: se unen en un solo estado con tres fases.
import { useState } from 'react';

interface Linea {
  numLinea: number;
  producto: string;
  cantidad: number;
  precio: number;
}

const LINEAS: Linea[] = [
  { numLinea: 1, producto: 'Portátil Gaming 16', cantidad: 1, precio: 1499 },
  { numLinea: 2, producto: 'Monitor 24 FHD', cantidad: 2, precio: 139 },
];

type Fase = 'editando' | 'confirmando' | 'confirmado';

const esperar = (ms: number) => new Promise((resolver) => setTimeout(resolver, ms));

export default function PedidoRevisado() {
  const [lineas, setLineas] = useState(LINEAS);
  const [numSeleccionada, setNumSeleccionada] = useState(1);
  const [fase, setFase] = useState<Fase>('editando');

  const total = lineas.reduce((suma, l) => suma + l.cantidad * l.precio, 0);
  const seleccionada = lineas.find((l) => l.numLinea === numSeleccionada);
  const editable = fase === 'editando';

  function sumarUnidad() {
    setLineas(
      lineas.map((l) =>
        l.numLinea === numSeleccionada ? { ...l, cantidad: l.cantidad + 1 } : l,
      ),
    );
  }

  async function confirmar() {
    setFase('confirmando');
    await esperar(300);
    setFase('confirmado');
  }

  return (
    <section className="tarjeta">
      <h2>Pedido ({fase})</h2>
      <ul>
        {lineas.map((l) => (
          <li key={l.numLinea}>
            <button
              aria-pressed={l.numLinea === numSeleccionada}
              disabled={!editable}
              onClick={() => setNumSeleccionada(l.numLinea)}
            >
              {l.producto}
            </button>{' '}
            x {l.cantidad}
          </li>
        ))}
      </ul>
      <p>Seleccionada: {seleccionada?.producto}. Total: {total} €</p>
      <div className="fila">
        <button disabled={!editable} onClick={sumarUnidad}>+1 unidad</button>
        <button disabled={!editable} onClick={confirmar}>Confirmar</button>
      </div>
    </section>
  );
}

Han desaparecido dos estados y ya no hay forma de que el total o la selección queden desfasados.

Ejercicio 10 (propuesto): Tablero de pedidos

Haz un tablero con una columna por estado de pedido (PENDIENTE, ENVIADO, ENTREGADO) y botones para mover cada pedido a la columna siguiente o anterior. Guarda una sola lista, con el estado dentro de cada pedido. Las columnas deben ser derivadas, con un componente Columna que no tenga estado propio.

Ejercicio 11 (propuesto): Deshacer y rehacer

Amplía el ejercicio 5 con botones “Deshacer” y “Rehacer”. Guarda { pasado: Tarea[][], presente: Tarea[], futuro: Tarea[][] }. Comprueba que funciona porque nunca mutas los arrays antiguos, y explica qué pasaría si alternar mutara una tarea.


Resumen

Concepto API Idea clave
Estado const [v, setV] = useState(inicial) Memoria del componente que sobrevive al render y provoca otro al cambiar
Tipado useState<Empleado \| null>(null) Indica el tipo si el valor inicial no lo dice todo
Inicialización perezosa useState(calcular) La función solo se ejecuta en el primer render
Reglas de los hooks Nivel superior, en componentes o hooks React identifica cada estado por su orden de llamada
Localidad Una copia por instancia Es privado y se destruye al desmontar
Instantánea contador es constante en cada render El setter no cambia la variable: pide otro render
Lotes Automático Un render por lote, no por setter; await parte el lote
Función actualizadora setV((actual) => actual + 1) Encadena sobre el valor pendiente; debe ser pura
Sin cambios Object.is Si es igual al actual, no hay actualización
Objetos { ...obj, campo: valor } Nunca mutar; copiar cada nivel del camino
Arrays map, filter, toSorted, with, [...a, x] Evita push, splice, sort, reverse
Estado derivado Calcula en el render Si se puede calcular, no lo guardes
Elevar estado Estado en el ancestro, props hacia abajo Una única fuente de verdad
Controlado valor + onCambiar El padre manda; el hijo pide
Estructura Uniones, ids, datos planos Haz imposibles los estados imposibles