La lección anterior terminó con un compromiso: ver la misma pantalla de Nómada Tareas —la lista de tareas con su filtro por responsable y su botón de marcar como hecha— escrita de cuatro formas distintas, para que la comparación sea real y no un catálogo de características. Empezamos por React, que es la más extendida y también la más fácil de malentender, porque su superficie es engañosamente pequeña: un puñado de funciones y una sintaxis rara. Lo difícil de React no es aprenderlo, es entender cuándo se vuelve a ejecutar tu código y por qué. Esta lección va exactamente de eso. Verás qué es JSX y en qué se convierte al compilar; cómo se escriben componentes de función, cómo se pasan props y cómo se componen; por qué las listas necesitan una key —y cerrarás por fin el paralelismo con tu reconciliar() por data-id de 06-06—; cómo funciona useState y por qué el estado debe tratarse como inmutable, donde lucirán las actualizaciones inmutables de 04-07; en qué se diferencian los eventos de React de tu addEventListener; qué es useEffect, y sobre todo para qué no es, con su array de dependencias, su función de limpieza —que es tu destruir() de 09-03— y el error clásico de usarlo para derivar estado; qué hacen useMemo, useCallback y useRef con el aviso de 09-01 sobre no optimizar sin medir; cómo se extrae lógica reutilizable en hooks personalizados, construyendo useTablero y useFiltroResponsable; la diferencia entre formularios controlados y no controlados; cómo funciona de verdad el DOM virtual; y cómo se cargan datos con sus tres problemas reales —condiciones de carrera, cancelación y doble ejecución—. Al final, la lista de tareas completa en React, comparada línea a línea con tu tablero-vista.js.

Contenido

  1. Qué es React y qué no es
  2. Poner en marcha un proyecto
  3. JSX: qué es y en qué se convierte
  4. Componentes de función
  5. props y composición
  6. Renderizado de listas y la key
  7. key: el mismo problema que resolviste en 06-06
  8. Renderizado condicional
  9. Estado con useState
  10. Por qué el estado se trata como inmutable
  11. La forma del estado importa más que el estado
  12. Eventos en React y la diferencia con addEventListener
  13. useEffect: qué es y qué no es
  14. El array de dependencias
  15. La función de limpieza: tu destruir()
  16. El error de derivar estado con efectos
  17. useMemo, useCallback y useRef
  18. No optimices sin medir
  19. Hooks personalizados: useTablero y useFiltroResponsable
  20. Las reglas de los hooks y por qué existen
  21. Formularios controlados y no controlados
  22. El DOM virtual y la reconciliación, de verdad
  23. El compilador de React y los componentes de servidor
  24. Cargar datos con useEffect y sus tres problemas
  25. Por qué en producción se usa una librería de datos
  26. El ecosistema mínimo
  27. Nómada Tareas en React: la lista completa
  28. Comparación lado a lado con tablero-vista.js
  29. Errores Comunes y Consejos
  30. Ejercicios
  31. Conclusión

  1. Qué es React y qué no es

React es una librería para construir interfaces de usuario. La palabra «librería» es deliberada y la distinción de 10-01 se aplica al pie de la letra: React resuelve un problema —describir la pantalla en función del estado y mantenerla sincronizada— y no resuelve ninguno más.

Lo que React te da:

  • Un modelo de componentes de función.
  • Un sistema de estado local (useState) y de efectos (useEffect).
  • Un motor de reconciliación que traduce tus descripciones en operaciones mínimas sobre el DOM.
  • Un modelo de eventos uniforme entre navegadores.

Lo que React no te da y tienes que elegir tú:

Necesidad React incluye Qué se usa en la práctica
Enrutado Nada React Router, TanStack Router, o el del meta-framework
Peticiones HTTP Nada fetch (tu pedirJson de 07-03), TanStack Query
Estado global Context, que es un mecanismo de transporte, no un almacén Redux Toolkit, Zustand, Jotai (10-03)
Formularios Nada más allá del estado React Hook Form, o a mano
Estilos Nada CSS normal, módulos CSS, utilidades, CSS-in-JS
Empaquetado Nada Vite (el que ya conoces de 09-05)
Pruebas Nada Jest o Vitest + Testing Library (08-03, 08-05)

Esa tabla es la definición operativa de «librería sin opiniones». Tiene una consecuencia práctica que verás en cuanto mires código ajeno: dos proyectos React pueden no parecerse en nada. También tiene una ventaja evidente: tu pedirJson con reintentos y AbortController de 07-03 se puede usar tal cual, sin adaptador ninguno, porque React no tiene opinión sobre cómo pides datos.

Un punto que conviene fijar desde el principio: React no es un lenguaje. Todo lo que vas a escribir es JavaScript, con una única extensión de sintaxis (JSX) que se compila a llamadas de función normales. Los map, filter y reduce de 04-05, la desestructuración de 04-07, el spread inmutable, las promesas de 05-06 y los módulos de 05-04 se usan exactamente igual. Esa es una de las razones de su popularidad: casi todo lo que sabes se transfiere.

  1. Poner en marcha un proyecto

Con Vite, que ya usas desde 09-05, un proyecto React nuevo se crea así:

npm create vite@latest nomada-react -- --template react
cd nomada-react
npm install
npm run dev

Lo que se instala son dos paquetes: react (el motor de componentes, independiente de la plataforma) y react-dom (el que sabe pintar en un navegador). Esa separación existe porque el mismo motor pinta en móvil nativo con React Native.

El punto de entrada es mínimo:

// src/main.jsx
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import App from './App.jsx';
import './estilos.css';

createRoot(document.getElementById('raiz')).render(
  <StrictMode>
    <App />
  </StrictMode>
);

Tres cosas que leer con atención:

  • createRoot(nodo) toma un elemento del DOM real —el mismo <div id="raiz"> de siempre— y lo convierte en el territorio de React. Fuera de ese nodo, React no toca nada. Puedes montar React en una esquina de una página existente; es lo que hace mucha gente al migrar.
  • .render(<App />) le dice qué pintar dentro.
  • <StrictMode> es un envoltorio de desarrollo que ejecuta cosas dos veces a propósito para destapar efectos mal escritos. No hace nada en producción. Lo vas a odiar en el apartado 24 y luego lo vas a agradecer.

  1. JSX: qué es y en qué se convierte

Esto es JSX:

const elemento = <h2 className="tarea__titulo">Presupuesto de la carpintería</h2>;

No es HTML dentro de JavaScript, ni una plantilla de texto. Es azúcar sintáctico que el compilador (esbuild dentro de Vite, o Babel) convierte en una llamada de función. Lo anterior se convierte, aproximadamente, en esto:

// Lo que produce el compilador (simplificado)
import { jsx } from 'react/jsx-runtime';

const elemento = jsx('h2', {
  className: 'tarea__titulo',
  children: 'Presupuesto de la carpintería'
});

Y lo que devuelve esa llamada es un objeto JavaScript corriente, no un nodo del DOM:

{
  type: 'h2',
  props: { className: 'tarea__titulo', children: 'Presupuesto de la carpintería' },
  key: null
}

Ese objeto es el elemento de React, el ladrillo del DOM virtual del que hablaba 10-01. Es ligero, es de solo lectura y no ha tocado el DOM. Compáralo con lo que hacía tu crearElemento de js/vista/dom.js: aquello creaba un nodo real inmediatamente; esto describe uno y decide después.

Entender que JSX son llamadas de función explica todas sus reglas, que de otro modo parecen arbitrarias:

Regla 1 · Un componente devuelve un solo elemento raíz. Porque return devuelve un valor, no dos. Cuando no quieres un contenedor extra, se usa el fragmento <>…</>:

return (
  <>
    <h2>{tarea.titulo}</h2>
    <p>{tarea.responsable}</p>
  </>
);

Regla 2 · Las llaves contienen una expresión JavaScript, no una sentencia. {tarea.titulo}, {horas * 2}, {esVencida ? '⚠' : ''} funcionan; un if o un for no, porque no producen valor. Por eso las listas se hacen con map y los condicionales con el operador ternario o con &&.

Regla 3 · Los atributos usan los nombres de las propiedades del DOM, no los del HTML. class es palabra reservada en JavaScript, así que se escribe className; for de las etiquetas es htmlFor; los manejadores son onClick, onInput, en camelCase. Los atributos data-* y aria-* son la excepción y se escriben tal cual, con guiones, porque no son propiedades reales.

<li className="tarea tarea--alta" data-id={6} aria-label="Tarea vencida">
  <label htmlFor="filtro">Responsable</label>
</li>

Regla 4 · {} interpola valores, y algunos valores no se pintan. null, undefined, false y true no producen nada. Esto es lo que hace posible el renderizado condicional del apartado 8, y también la causa de un error clásico: {tareas.length && <Lista/>} pinta un 0 en pantalla cuando la lista está vacía, porque 0 se pinta.

Regla 5 · El contenido interpolado se escapa. {tarea.titulo} se inserta como texto, nunca como HTML. Es el equivalente de tu textContent frente a innerHTML de 06-02, y protege contra inyección de la misma forma. Para insertar HTML de verdad hay que usar una propiedad con un nombre deliberadamente incómodo (dangerouslySetInnerHTML), que es exactamente el aviso que quieres tener.

  1. Componentes de función

Un componente de React es una función que recibe un objeto de propiedades y devuelve una descripción de pantalla. Nada más:

// src/componentes/TarjetaTarea.jsx
export function TarjetaTarea({ tarea }) {
  return (
    <li className="tarea">
      <h3 className="tarea__titulo">{tarea.titulo}</h3>
      <p className="tarea__meta">
        {tarea.responsable ?? 'sin asignar'} · {tarea.horasEstimadas} h
      </p>
    </li>
  );
}

Dos convenciones que no son negociables:

  1. El nombre empieza por mayúscula. No es estilo: es cómo el compilador distingue <TarjetaTarea/> (tu componente) de <li> (una etiqueta HTML). En minúscula, JSX genera la cadena 'tarjetatarea' y React intentará crear un elemento HTML inexistente.
  2. El componente debe ser puro respecto a sus entradas. Con las mismas props y el mismo estado, la misma salida. No modifica sus props, no escribe en variables externas durante el renderizado, no toca el DOM directamente. Esta es la f de UI = f(estado), y todo el modelo depende de que se cumpla.

Fíjate en la desestructuración de la firma: function TarjetaTarea({ tarea }). Es la desestructuración de objetos en parámetros de 04-07, y es el estilo dominante porque documenta qué props recibe el componente con solo leer la primera línea.

  1. props y composición

Las props son los datos que un componente recibe de su padre. Fluyen hacia abajo y son de solo lectura: un componente no debe modificar el objeto que recibe.

// El padre decide qué datos y qué comportamiento recibe el hijo
<TarjetaTarea tarea={tarea} alMarcarHecha={marcarHecha} />

Se pasan tres tipos de cosas, y conviene distinguirlas:

Tipo de prop Ejemplo Para qué
Datos tarea={tarea}, horas={45} Lo que hay que pintar
Funciones alMarcarHecha={marcarHecha} Cómo avisar hacia arriba de que ha pasado algo
Contenido children Composición: qué va dentro

Las props de función son el mecanismo de comunicación hacia arriba, y son el equivalente directo de tus CustomEvent de 06-04, con una diferencia importante: el CustomEvent viaja por el DOM y cualquiera puede escucharlo; la prop de función es un contrato explícito entre padre e hijo que se lee en la firma.

La composición con children es la pieza que más se infrautiliza:

// Un componente contenedor genérico
export function Panel({ titulo, children }) {
  return (
    <section className="panel">
      <h2 className="panel__titulo">{titulo}</h2>
      <div className="panel__cuerpo">{children}</div>
    </section>
  );
}

// Uso: lo que va dentro llega como children
<Panel titulo="Tareas abiertas">
  <ListaTareas tareas={visibles} />
</Panel>

children no es magia: es una prop más, que JSX rellena con lo que hayas escrito entre la etiqueta de apertura y la de cierre. Y da un patrón muy potente: componentes que definen el hueco sin saber qué va dentro, que es lo que hacen los <slot> de los web components y, como verás en 10-04, los de Vue.

  1. Renderizado de listas y la key

Una lista se pinta con map, el mismo de 04-04:

export function ListaTareas({ tareas }) {
  return (
    <ul className="lista-tareas">
      {tareas.map((tarea) => (
        <TarjetaTarea key={tarea.id} tarea={tarea} />
      ))}
    </ul>
  );
}

tareas.map(...) devuelve un array de elementos de React, y JSX sabe pintar un array poniendo sus elementos uno tras otro. Nada nuevo salvo un detalle: key.

  1. key: el mismo problema que resolviste en 06-06

Este es el momento de cerrar el círculo que se abrió en 06-06 y que 09-05 dejó apuntado.

Cuando el estado cambia, React vuelve a ejecutar ListaTareas y obtiene un array nuevo de descripciones. Ahora tiene que decidir, para cada elemento del array nuevo, cuál del array viejo le corresponde. Sin más información, la única heurística disponible es la posición: el primero con el primero, el segundo con el segundo.

Esa heurística falla exactamente en los mismos casos que te obligaron a escribir reconciliar con data-id. Supón el backlog canónico y que se elimina la tarea 2:

Posición Antes Después Qué haría React sin key
0 Tarea 1 · Sala polivalente Tarea 1 · Sala polivalente Reutiliza el nodo. Correcto
1 Tarea 2 · Cartelería Tarea 3 · Web de reservas Reutiliza el nodo de la 2 y le cambia el texto
2 Tarea 3 · Web de reservas Tarea 4 · Inventario Reutiliza el nodo de la 3 y le cambia el texto
3 Tarea 4 · Inventario Tarea 5 · Guía Reutiliza y cambia
4 Tarea 5 · Guía Tarea 6 · Carpintería Reutiliza y cambia
5 Tarea 6 · Carpintería Elimina el último nodo

Visualmente el resultado es correcto. Pero cinco nodos han cambiado de dato, y con ellos:

  • El foco, que estaba en el botón «Empezar» de la tarea 3, acaba en un botón que ahora muestra la tarea 4.
  • Cualquier transición CSS en curso se aplica al elemento equivocado.
  • El estado interno de los componentes hijos —un menú desplegado, un <input> a medio escribir— se atribuye a la tarea que no era.
  • Se hacen cinco actualizaciones de texto cuando bastaba con eliminar un nodo.

Es palabra por palabra el análisis que hiciste en el ejercicio de 06-06. La solución es la misma: una clave estable que identifique el dato, no su posición.

<TarjetaTarea key={tarea.id} tarea={tarea} />

Con key, React construye un mapa de clave → elemento anterior —exactamente el Map que construye tu reconciliar con nodo.dataset.id— y empareja por clave en lugar de por posición. Reutiliza los que siguen vivos, mueve los que han cambiado de sitio y elimina los sobrantes.

Las tres reglas de key, que son las mismas que las de tu data-id:

Regla Por qué
Estable: el mismo dato tiene siempre la misma clave Si cambia, React destruye y recrea: pierdes foco, estado y transiciones
Única entre hermanos: no globalmente Solo se compara dentro de la misma lista
Nunca el índice si la lista se reordena, filtra o admite eliminaciones El índice es la posición: usarlo equivale a no poner clave

Un matiz sobre el índice, porque hay muchísima desinformación: usar el índice como clave no es siempre un error. Si la lista nunca se reordena, nunca se filtra, nunca se inserta ni se elimina por el medio y sus elementos no tienen estado propio, el índice es exactamente igual de bueno que un id. El problema es que esas cuatro condiciones dejan de cumplirse el día que alguien añade un filtro, y entonces el fallo es sutil, intermitente y difícil de reproducir. La regla práctica: si tienes un id, úsalo.

Y un aviso que ahorra horas: key no es una prop. React la consume y el componente no la recibe. Si TarjetaTarea necesita el id, hay que pasarlo también: <TarjetaTarea key={t.id} tarea={t} /> funciona porque el id va dentro de tarea.

  1. Renderizado condicional

Tres formas, con criterios claros para elegir:

// 1 · Operador ternario: cuando hay dos alternativas
{tarea.estado === 'hecha'
  ? <span className="marca">✓ Hecha</span>
  : <button onClick={avanzar}>Empezar</button>}

// 2 · && : cuando o se pinta algo o no se pinta nada
{tarea.estaVencida(HOY) && <span className="aviso">⚠ Vencida</span>}

// 3 · Return anticipado: cuando el componente entero cambia
export function ListaTareas({ tareas }) {
  if (tareas.length === 0) {
    return <p className="vacio">Ninguna tarea coincide con el filtro.</p>;
  }
  return <ul className="lista-tareas">{/* … */}</ul>;
}

La trampa del && merece su propio ejemplo porque cae todo el mundo una vez:

{tareas.length && <Resumen tareas={tareas} />}

Si tareas está vacío, tareas.length es 0. El operador && devuelve 0, y 0 no es de los valores que React ignora: se pinta. Aparece un cero suelto en la pantalla. La solución es convertirlo en booleano de verdad:

{tareas.length > 0 && <Resumen tareas={tareas} />}

Es un recordatorio directo de 01-07: los valores falsy no son todos equivalentes, y aquí la diferencia entre 0 y false se ve literalmente en pantalla.

  1. Estado con useState

Hasta ahora todo era estático. El estado es lo que hace que la pantalla cambie.

import { useState } from 'react';

export function FiltroResponsable({ responsables, valor, alCambiar }) {
  return (
    <label className="filtro">
      Responsable:
      <select value={valor ?? ''} onChange={(e) => alCambiar(e.target.value || null)}>
        <option value="">Todos</option>
        {responsables.map((nombre) => (
          <option key={nombre} value={nombre}>{nombre}</option>
        ))}
      </select>
    </label>
  );
}

Ese componente no tiene estado propio: lo recibe y avisa hacia arriba. El estado vive en el padre:

export function App() {
  const [responsable, setResponsable] = useState(null);
  // …
}

useState(inicial) devuelve un array de dos posiciones que se desestructura (04-06): el valor actual y la función para cambiarlo. Cuatro puntos esenciales:

1 · El valor inicial solo se usa en el primer renderizado. En los siguientes, React ignora el argumento y devuelve el valor guardado. Si el valor inicial es caro de calcular, se pasa una función para que solo se ejecute una vez:

const [tablero] = useState(() => crearTableroDesde(BACKLOG));  // se llama UNA vez
const [tableroMal] = useState(crearTableroDesde(BACKLOG));     // se ejecuta en CADA render

Esta distinción es exactamente la diferencia entre pasar una función y pasar su resultado, de 03-02. Con un tablero de 600 tareas, la segunda forma construye 600 objetos en cada render y tira 599 de ellos.

2 · Llamar a la función de actualización pide un nuevo renderizado. No cambia la variable actual: responsable sigue valiendo lo mismo hasta el final de esta ejecución. Es el error de principiante número uno:

function alPulsar() {
  setResponsable('Iván');
  console.log(responsable);   // ← todavía null: la variable NO cambia aquí
}

Es un closure (03-04): responsable es una constante capturada en esta ejecución de la función componente. El valor nuevo llega en la siguiente ejecución. Verlo así, y no como «React tarda», elimina la confusión de raíz.

3 · Las actualizaciones se agrupan. Varios set* seguidos producen un solo renderizado. Y si el valor nuevo depende del anterior, hay que usar la forma de función:

setContador(contador + 1);
setContador(contador + 1);   // ← ambos leen el MISMO valor: incrementa 1, no 2

setContador((n) => n + 1);
setContador((n) => n + 1);   // ← cada uno recibe el resultado del anterior: incrementa 2

4 · Si el valor nuevo es idéntico (Object.is) al anterior, React no vuelve a renderizar. Aquí está el motivo por el que la inmutabilidad no es opcional.

  1. Por qué el estado se trata como inmutable

React compara el valor nuevo con el anterior usando Object.is, que para objetos y arrays compara identidad de referencia, no contenido. Es la comparación de 04-08.

const tareas = [/* … */];
tareas[5].estado = 'hecha';      // el objeto cambia
setTareas(tareas);               // ← misma referencia: React NO renderiza

La pantalla no se actualiza aunque el dato haya cambiado. El error no es de React: es que le has entregado el mismo array de siempre y le has pedido que note la diferencia.

La solución es la que llevas usando desde 04-07: crear valores nuevos en lugar de modificar los existentes.

// Marcar la tarea 6 como hecha, de forma inmutable
setTareas((actuales) =>
  actuales.map((t) => (t.id === 6 ? { ...t, estado: 'hecha' } : t))
);

Léelo con cuidado, porque es el patrón que más vas a escribir en React:

  • map devuelve un array nuevo: la referencia cambia, React detecta el cambio.
  • Para las tareas que no son la 6 devuelve el mismo objeto: la referencia se conserva. Esto no es un detalle, es una optimización clave — permite que React (y React.memo) sepa que esas tarjetas no han cambiado.
  • Para la 6, { ...t, estado: 'hecha' } crea un objeto nuevo con todo lo anterior y el campo cambiado. Es el spread de 04-07, exactamente.

Aquí hay una tensión real con Nómada Tareas que conviene nombrar. Tu clase Tarea tiene #estado privado y un método cambiarEstado que muta el objeto validando la R6. Eso es buen diseño orientado a objetos y es incompatible con la detección por referencia de React. Hay tres salidas honestas:

Opción Cómo Cuándo conviene
Datos planos en el estado de React, clases fuera El estado guarda objetos literales; las reglas viven en funciones puras que reciben y devuelven objetos Lo más común y lo más simple
Clases inmutables cambiarEstado devuelve una instancia nueva en lugar de mutar Mantiene el modelo, requiere reescribirlo
El tablero fuera de React, sincronizado El Tablero sigue siendo tuyo y un contador de versión fuerza el renderizado Poco recomendable: dos fuentes de verdad

Para la reimplementación del apartado 27 usaremos la primera, que es lo que hace la mayoría, y lo diremos explícitamente. Es un ejemplo concreto de lo que 10-01 llamaba «el coste de la dependencia»: el framework tiene opinión sobre la forma de tus datos.

  1. La forma del estado importa más que el estado

Un consejo que ahorra mucho sufrimiento y que se aprende tarde: casi todos los problemas difíciles de estado en React son problemas de estado mal modelado.

Tres reglas prácticas:

No guardes lo que puedes calcular. Esto está mal:

const [tareas, setTareas] = useState(BACKLOG);
const [horasAbiertas, setHorasAbiertas] = useState(45);   // ← redundante y sincronizable a mano

Si horasAbiertas se deduce de tareas, guardarlo crea dos fuentes de verdad que hay que mantener a mano — el problema 1 de 10-01, reintroducido dentro del framework. Lo correcto:

const [tareas, setTareas] = useState(BACKLOG);
const horasAbiertas = tareas
  .filter((t) => t.estado !== 'hecha')
  .reduce((s, t) => s + t.horasEstimadas, 0);   // se recalcula en cada render, y está bien

Sí: se recalcula en cada renderizado. Y sí, está bien. Sumar seis números, o seiscientos, es irrelevante comparado con el trabajo de pintar. Solo si mides que duele se memoiza (apartado 17).

Guarda identificadores, no objetos. Si tienes tareaSeleccionada como objeto y la tarea cambia en la lista, tienes una copia obsoleta. Guarda idSeleccionado y busca la tarea al pintar.

Evita el estado imposible. { cargando: true, error: 'fallo' } es un estado que no debería existir. Modelarlo como una sola variable con valores 'inactivo' | 'cargando' | 'listo' | 'error' elimina la combinación imposible por construcción. Es el mismo razonamiento que te llevó a SIGUIENTE[estado] en la R6.

  1. Eventos en React y la diferencia con addEventListener

En React los manejadores se declaran como props:

<button onClick={() => alMarcarHecha(tarea.id)}>Marcar hecha</button>

Parece el onclick de HTML de los años noventa, pero funciona de forma completamente distinta. Tabla comparativa con lo que conoces de 06-03 y 06-04:

Aspecto addEventListener (06-03) React
Dónde se registra En el nodo concreto React usa delegación en la raíz de la aplicación
Cuántas escuchas hay Una por nodo Una por tipo de evento, para toda la aplicación
Qué recibe el manejador El Event nativo Un SyntheticEvent que normaliza diferencias entre navegadores
Acceso al evento nativo Directo e.nativeEvent
Cancelar el comportamiento e.preventDefault() o return false en algunos casos Solo e.preventDefault()
Retirar la escucha removeEventListener o signal Automático al desmontar
Nombre del evento de escritura input onChange (que se comporta como el input nativo)

Dos consecuencias prácticas:

React ya hace delegación por ti. El patrón que construiste en 06-04 con closest('[data-accion]') para no poner 600 escuchas es exactamente lo que hace React internamente. Escribir onClick en cada una de las 600 tarjetas no crea 600 escuchas del DOM: crea una en la raíz. Es una de las cosas que un framework te da resuelta de fábrica y que tú resolviste a mano.

onChange no es el change nativo. Es una de las pocas incoherencias históricas de React: onChange se dispara en cada pulsación, como el input nativo, no al perder el foco como el change del DOM. Si vienes de JavaScript puro, es la trampa que más desconcierta.

  1. useEffect: qué es y qué no es

useEffect es el hook peor entendido de React, y la mayoría de los problemas vienen de una idea equivocada: creer que es «código que se ejecuta cuando algo cambia». No lo es.

useEffect sirve para sincronizar tu componente con un sistema externo a React.

«Sistema externo» significa: el DOM fuera de tu árbol, un WebSocket, un setInterval, localStorage, el título del documento, una librería de terceros, el observador de intersección. Cosas que existen fuera del modelo UI = f(estado) y que hay que encender y apagar.

import { useEffect } from 'react';

function TituloDocumento({ pendientes }) {
  useEffect(() => {
    document.title = `Nómada Tareas (${pendientes})`;
  }, [pendientes]);

  return null;
}

El document.title es un sistema externo: React no lo gestiona. Sincronizarlo con el estado es exactamente el caso de uso.

Y ahora la lista de para qué NO es useEffect, que es más útil:

No lo uses para… Usa en su lugar
Calcular un valor a partir del estado o las props Calcularlo directamente durante el renderizado
Reaccionar a un clic del usuario El manejador del evento
Transformar datos antes de pintarlos Una expresión, o useMemo si mides que duele
Actualizar estado cuando cambia una prop Repensar el estado; casi siempre es estado derivado
Cargar datos en una aplicación de producción Una librería de datos (apartado 25)

La regla que resume todo: si el efecto no tiene nada que apagar y no toca nada fuera de React, probablemente no debería ser un efecto.

  1. El array de dependencias

El segundo argumento de useEffect controla cuándo se vuelve a ejecutar:

useEffect(() => { /* … */ });              // en CADA renderizado
useEffect(() => { /* … */ }, []);          // solo al montar
useEffect(() => { /* … */ }, [id, filtro]); // al montar y cuando cambie id o filtro

La comparación de las dependencias se hace con Object.is, la misma del apartado 10. Y de ahí sale el problema más frecuente de todos:

function Lista({ tareas, filtros }) {
  useEffect(() => {
    console.log('los filtros han cambiado');
  }, [filtros]);      // ← si el padre crea {responsable: null} en cada render, esto se dispara SIEMPRE
}

Un objeto literal escrito dentro del componente padre es un objeto nuevo en cada renderizado. Su referencia cambia siempre, así que la dependencia siempre parece haber cambiado. Las soluciones, por orden de preferencia:

  1. Depender de valores primitivos: [filtros.responsable, filtros.texto] en lugar de [filtros]. Casi siempre es lo correcto.
  2. Mover la creación del objeto fuera del componente si es constante.
  3. Memoizar el objeto con useMemo en el padre. Es el último recurso, no el primero.

Y una regla que no admite excepciones prácticas: declara todas las dependencias que el efecto usa. La regla de ESLint react-hooks/exhaustive-deps lo comprueba, y —conectando con 08-02— es una de las razones por las que un proyecto React sin ESLint es una mala idea. Cuando sientas la tentación de silenciarla, casi siempre significa que el efecto está mal planteado, no que la regla se equivoque.

  1. La función de limpieza: tu destruir()

Si el efecto devuelve una función, React la llama antes de la siguiente ejecución del efecto y al desmontar el componente. Es el mecanismo de ciclo de vida de 10-01, y es literalmente tu destruir() de 09-03.

Compara. Lo que escribiste en JavaScript puro:

// js/vista/tablero-vista.js — 09-03
export class TableroVista {
  #controlador = new AbortController();

  constructor(contenedor) {
    this.canal = new CanalTablero();
    this.canal.suscribir(this.#alRecibir);
    window.addEventListener('resize', this.#alRedimensionar,
                            { signal: this.#controlador.signal });
  }

  destruir() {
    this.#controlador.abort();      // quita TODAS las escuchas registradas con la señal
    this.canal.cerrar();
    clearInterval(this.#reloj);
  }
}

Y lo mismo en React:

function TableroEnVivo({ alRecibir }) {
  useEffect(() => {
    const canal = new CanalTablero();
    canal.suscribir(alRecibir);

    const controlador = new AbortController();
    window.addEventListener('resize', alRedimensionar, { signal: controlador.signal });

    const reloj = setInterval(() => recalcularVencidas(), 60_000);

    return () => {                  // ← esto es destruir()
      canal.cerrar();
      controlador.abort();
      clearInterval(reloj);
    };
  }, [alRecibir]);

  return null;
}

El contenido de la limpieza es idéntico. Lo que cambia es quién la llama: en tu versión, alguien tiene que acordarse de invocar destruir() desde el sitio correcto en el momento correcto —y si reconciliar elimina un nodo, nadie avisa—. En React, la llamada está garantizada. Esa es la mejora exacta: no desaparece el trabajo, desaparece el olvido.

Un detalle que confunde al principio: la limpieza se ejecuta también entre ejecuciones sucesivas del efecto, no solo al desmontar. Si alRecibir cambia, React primero limpia el canal viejo y luego crea uno nuevo. Es correcto y es lo que quieres: el efecto describe «mientras estas dependencias valgan esto, debe existir esta suscripción».

  1. El error de derivar estado con efectos

Este es el antipatrón más extendido de React. Se ve así:

// ❌ MAL: un efecto para calcular algo que se deduce del estado
function Panel({ tareas }) {
  const [horasAbiertas, setHorasAbiertas] = useState(0);

  useEffect(() => {
    setHorasAbiertas(
      tareas.filter((t) => t.estado !== 'hecha')
            .reduce((s, t) => s + t.horasEstimadas, 0)
    );
  }, [tareas]);

  return <p>{horasAbiertas} h abiertas</p>;
}

Tres cosas van mal, y todas son consecuencias, no opiniones:

  1. Hay dos renderizados por cada cambio. El primero pinta el valor viejo; el efecto se ejecuta y cambia el estado; el segundo pinta el correcto. El usuario puede ver el número antiguo durante un fotograma.
  2. Hay dos fuentes de verdad. tareas y horasAbiertas pueden desincronizarse: es el problema 1 de 10-01 reinventado dentro del framework que venía a resolverlo.
  3. Es más código y más lento.

La versión correcta cabe en una línea:

// ✅ BIEN: se calcula durante el renderizado
function Panel({ tareas }) {
  const horasAbiertas = tareas
    .filter((t) => t.estado !== 'hecha')
    .reduce((s, t) => s + t.horasEstimadas, 0);

  return <p>{horasAbiertas} h abiertas</p>;
}

La regla, memorizable: si puedes calcularlo durante el renderizado, calcúlalo durante el renderizado. No es una micro-optimización: es evitar reintroducir el problema que el modelo declarativo venía a eliminar.

  1. useMemo, useCallback y useRef

Tres hooks que se confunden constantemente. Tabla primero:

Hook Qué guarda entre renderizados Cuándo se recalcula Para qué sirve de verdad
useMemo(fn, deps) El resultado de fn() Cuando cambia alguna dependencia Evitar un cálculo caro, o mantener estable la referencia de un objeto
useCallback(fn, deps) La función misma Cuando cambia alguna dependencia Mantener estable la referencia de una función que se pasa como prop o dependencia
useRef(inicial) Un objeto { current } mutable Nunca; persiste siempre Guardar algo que no debe provocar renderizado, o acceder a un nodo real del DOM

useMemo es exactamente tu caché por versión de 09-02, con las dependencias haciendo de contador de versión:

// Tu memoización de 09-02, con la sintaxis de React
const visibles = useMemo(
  () => ordenarPorPrioridad(tareas.filter((t) => responsable === null || t.responsable === responsable)),
  [tareas, responsable]
);

useCallback(fn, deps) es literalmente useMemo(() => fn, deps). Existe porque el caso es tan frecuente que merecía atajo:

const marcarHecha = useCallback((id) => {
  setTareas((actuales) => actuales.map((t) => (t.id === id ? { ...t, estado: 'hecha' } : t)));
}, []);   // sin dependencias: usa la forma de función de setTareas, no lee nada de fuera

Fíjate en el array vacío: es posible precisamente porque setTareas recibe una función en lugar de leer tareas del closure. Ese patrón es el que hace que las funciones sean estables de verdad.

useRef es distinto de los otros dos, y tiene dos usos que no se parecen:

// Uso 1: acceder a un nodo real del DOM (para medir, enfocar, reproducir)
function CampoBusqueda() {
  const campo = useRef(null);

  useEffect(() => { campo.current.focus(); }, []);

  return <input ref={campo} type="search" />;
}

// Uso 2: guardar un valor mutable que NO debe provocar renderizado
function Cronometro() {
  const idIntervalo = useRef(null);
  // cambiar idIntervalo.current no vuelve a pintar nada
}

El uso 1 es tu escotilla de emergencia hacia el DOM real: cuando necesitas medir con getBoundingClientRect (09-04), enfocar un campo o integrar una librería que espera un nodo. Es legítimo y a veces imprescindible, pero cada ref que modifica el DOM directamente es un trozo de pantalla que sale del modelo UI = f(estado).

  1. No optimices sin medir

Aquí conviene ser tajante, porque es donde más tiempo se pierde en React y donde 09-01 tiene más que decir.

useMemo y useCallback no son gratis. Cada uno guarda un valor y compara un array de dependencias en cada renderizado. Envolver un cálculo trivial cuesta más que el cálculo:

// ❌ Absurdo: comparar el array de dependencias cuesta más que la suma
const total = useMemo(() => a + b, [a, b]);

// ❌ Casi siempre inútil: el hijo no está memoizado, así que se repinta igual
const alPulsar = useCallback(() => setAbierto(true), []);

Ese segundo caso es el más común y el más inútil: useCallback solo aporta algo si el destinatario de la función compara sus props, es decir, si está envuelto en React.memo o si la función es dependencia de un efecto. Si no, estás pagando por una estabilidad que nadie usa.

El procedimiento correcto es el de 09-01, sin atajos:

  1. Mide. El React DevTools Profiler graba una interacción y muestra qué componentes se han renderizado, cuántas veces y cuánto han tardado. Es el equivalente del panel Performance para React.
  2. Encuentra el componente caro. Casi siempre es uno: una lista larga, un gráfico, un cálculo pesado.
  3. Comprueba por qué se renderiza. El Profiler dice qué prop cambió. Muchas veces la respuesta es «una función nueva en cada render» y ahí sí useCallback sirve.
  4. Aplica la optimización mínima y vuelve a medir.

Y el mismo aviso de 09-04: la optimización que más rinde en listas largas no es memoizar, es no pintar 600 filas. La virtualización que construiste sigue siendo la respuesta correcta, con librerías que la implementan por ti.

Una nota de futuro que ahorra ansiedad: el compilador de React (apartado 23) está diseñado precisamente para insertar estas memoizaciones automáticamente y hacer innecesaria la mayor parte de este trabajo manual.

  1. Hooks personalizados: useTablero y useFiltroResponsable

Un hook personalizado es una función cuyo nombre empieza por use y que llama a otros hooks. No hay más definición. Su valor es enorme: permite extraer lógica con estado —no solo cálculos— y reutilizarla entre componentes.

Es el equivalente de lo que tus módulos util/ hacían con funciones puras, pero para lógica que tiene estado y ciclo de vida.

// src/hooks/useTablero.js
import { useState, useCallback, useMemo } from 'react';
import { SIGUIENTE, ETIQUETA } from '../dominio/reglas.js';

/**
 * Estado del tablero de Nómada Tareas, con sus operaciones.
 * Las reglas de negocio (R5, R6) viven en dominio/reglas.js, fuera de React.
 */
export function useTablero(tareasIniciales) {
  const [tareas, setTareas] = useState(tareasIniciales);

  const cambiarEstado = useCallback((id, destino) => {
    setTareas((actuales) =>
      actuales.map((t) => {
        if (t.id !== id) return t;                     // misma referencia: no cambia
        if (SIGUIENTE[t.estado] !== destino) return t;  // R6: transición no permitida
        return { ...t, estado: destino };               // objeto nuevo (04-07)
      })
    );
  }, []);

  const marcarHecha = useCallback((id) => cambiarEstado(id, 'hecha'), [cambiarEstado]);

  const resumen = useMemo(() => {
    const abiertas = tareas.filter((t) => t.estado !== 'hecha');
    return {
      total: tareas.length,
      horasTotales: tareas.reduce((s, t) => s + t.horasEstimadas, 0),
      horasAbiertas: abiertas.reduce((s, t) => s + t.horasEstimadas, 0),
      pendientes: tareas.filter((t) => t.estado === 'pendiente').length
    };
  }, [tareas]);

  return { tareas, cambiarEstado, marcarHecha, resumen };
}
// src/hooks/useFiltroResponsable.js
import { useState, useMemo } from 'react';

/** Filtro por responsable: su estado, la lista de responsables y el resultado. */
export function useFiltroResponsable(tareas) {
  const [responsable, setResponsable] = useState(null);

  const responsables = useMemo(
    () => [...new Set(tareas.map((t) => t.responsable).filter(Boolean))].sort(),
    [tareas]
  );

  const visibles = useMemo(
    () => (responsable === null ? tareas : tareas.filter((t) => t.responsable === responsable)),
    [tareas, responsable]
  );

  return { responsable, setResponsable, responsables, visibles };
}

Cinco cosas que aprender de estos dos hooks:

  1. Devuelven un objeto, no un array. Los arrays son cómodos cuando hay dos valores (como useState); con cinco, un objeto es autodocumentado.
  2. Las reglas de negocio están fuera. SIGUIENTE y las R1–R10 viven en dominio/reglas.js, en JavaScript puro. Es la disciplina de 10-01: la vista es del framework, el modelo es tuyo. Si mañana cambias a Vue, este fichero no se toca.
  3. Cada llamada al hook crea su propio estado. Dos componentes que usen useFiltroResponsable tienen dos filtros independientes. Un hook no comparte estado: comparte lógica. Confundir esto es el malentendido número uno sobre los hooks.
  4. useMemo aquí sí tiene un motivo, y no es el rendimiento del cálculo: es mantener estable la referencia de visibles y responsables para que los componentes hijos memoizados no se repinten sin motivo.
  5. new Set(...) de 04-05 elimina duplicados y filter(Boolean) descarta los null de la R8: dos utilidades de JavaScript puro que se usan igual dentro de React.

  1. Las reglas de los hooks y por qué existen

Dos reglas, y una explicación que casi nunca se da:

  1. Solo se llaman en el nivel superior de un componente o de otro hook. Nunca dentro de un if, un bucle, un try o una función anidada.
  2. Solo se llaman desde componentes de React o desde hooks personalizados.

El motivo de la primera es puramente mecánico. React no sabe cómo se llaman tus variables de estado: guarda los valores en una lista y los identifica por el orden de llamada. La primera llamada a useState de este componente es la posición 0, la segunda la posición 1, y así.

// ❌ Esto rompe la correspondencia
function Componente({ mostrar }) {
  if (mostrar) {
    const [a, setA] = useState(1);   // ← a veces es la posición 0, a veces no existe
  }
  const [b, setB] = useState(2);      // ← a veces posición 1, a veces posición 0
}

Cuando mostrar cambia de true a false, b recibe el valor que era de a. No hay error, hay datos cruzados. Por eso la regla es absoluta y por eso existe la regla de ESLint react-hooks/rules-of-hooks, que debe estar activada.

La forma correcta de tener un hook condicional es extraer el trozo condicional a su propio componente, que a veces se pinta y a veces no.

  1. Formularios controlados y no controlados

Dos enfoques, y la elección se hace por un criterio claro.

Controlado: el valor del campo vive en el estado de React. El campo solo muestra lo que el estado dice.

function BuscadorTareas({ alBuscar }) {
  const [texto, setTexto] = useState('');

  return (
    <input
      type="search"
      value={texto}                                  // ← la fuente de la verdad es el estado
      onChange={(e) => setTexto(e.target.value)}      // ← cada tecla actualiza el estado
      placeholder="Buscar tareas"
    />
  );
}

No controlado: el valor vive en el DOM, como toda la vida, y se lee cuando hace falta.

function FormularioTarea({ alCrear }) {
  const formulario = useRef(null);

  function enviar(e) {
    e.preventDefault();
    const datos = Object.fromEntries(new FormData(e.currentTarget));   // 06-07
    alCrear(datos);
    e.currentTarget.reset();
  }

  return (
    <form ref={formulario} onSubmit={enviar}>
      <input name="titulo" required minLength={3} />
      <input name="horasEstimadas" type="number" min={1} max={40} required />
      <button type="submit">Crear tarea</button>
    </form>
  );
}

Fíjate en el segundo: FormData y Object.fromEntries son exactamente los de 06-07, y los atributos required, min y max son la validación nativa del navegador implementando las reglas R2 y R3. No hace falta React para eso, y usarlo es más simple, más accesible y más rápido.

Criterio Controlado No controlado
Fuente de la verdad El estado de React El DOM
Renderizados al escribir Uno por tecla Ninguno
Validación en vivo mientras se escribe Fácil Difícil
Deshabilitar el botón según el contenido Fácil Difícil
Formatear mientras se escribe (teléfono, importe) Fácil Muy difícil
Formulario grande y sencillo Costoso Ideal
Integración con validación nativa de HTML Se pierde parte Completa

La regla práctica: controlado cuando la interfaz debe reaccionar a lo que se escribe; no controlado cuando solo importa el valor final. Un buscador con filtro en vivo es controlado (y con el debounce de 09-02, que sigue siendo necesario). Un formulario de alta de tarea es perfectamente no controlado.

  1. El DOM virtual y la reconciliación, de verdad

Ya sabes qué es un elemento de React (apartado 3) y por qué necesita key (apartado 7). Falta juntar las piezas del mecanismo completo, porque explica todos los comportamientos raros.

Cuando cambia el estado de un componente, React hace esto:

graph TD
  S[Cambia el estado] --> R["Renderizado: se ejecuta la función<br/>del componente y las de sus hijos"]
  R --> A["Árbol nuevo de elementos"]
  A --> D["Reconciliación: comparar<br/>árbol nuevo con árbol anterior"]
  D --> L["Lista de operaciones mínimas"]
  L --> C["Confirmación: aplicar al DOM real,<br/>ejecutar limpiezas y efectos"]

Las tres reglas del algoritmo de comparación, que son heurísticas y no un algoritmo óptimo:

Regla 1 · Tipos distintos, subárbol nuevo. Si en la misma posición había un <div> y ahora hay un <section>, React no compara nada dentro: destruye todo el subárbol y lo crea de nuevo. Se pierde el estado de todos los componentes de dentro. Esto explica un fallo desconcertante:

// ❌ El componente cambia de tipo según la condición: se pierde el estado interno
{compacto ? <div><Lista tareas={t}/></div> : <section><Lista tareas={t}/></section>}

Regla 2 · Mismo tipo, se actualizan los atributos y se compara dentro. El nodo del DOM se conserva; solo cambian las propiedades que difieren. Es lo que hace tu pintarTarjeta(tarea, existente) cuando recibe un nodo.

Regla 3 · En listas, se empareja por key; sin key, por posición. El apartado 7 entero.

Lo que cuesta, con honestidad:

  • Se ejecutan las funciones de todos los componentes afectados, aunque el resultado sea idéntico. Con 600 tarjetas, cambiar el filtro ejecuta 600 funciones para descubrir que 594 no han cambiado. Es el coste inherente del DOM virtual que anunciaba 10-01: proporcional a cuánto hay, no a cuánto ha cambiado.
  • Se crean 600 objetos descriptores que serán basura en el siguiente ciclo. El recolector de 09-03 tiene trabajo.
  • A cambio, las operaciones sobre el DOM real sí son mínimas, que es donde está el coste caro (recálculo de estilo y disposición, 09-04).

Ese es el intercambio exacto: más trabajo en JavaScript, menos en el DOM. Como en tu aplicación mediste que el DOM era el cuello de botella y no el JavaScript, el intercambio suele salir a favor. Pero no siempre: con listas muy grandes o actualizaciones muy frecuentes, se nota, y por eso existen React.memo, useMemo y la virtualización.

Una nota sobre el renderizado concurrente: React moderno puede interrumpir un renderizado en curso si llega algo más urgente (una pulsación de tecla), y retomarlo después. Eso es lo que hacen useTransition y useDeferredValue, que son el equivalente conceptual de tu troceado con porLotes de 09-02: evitar que una actualización grande bloquee el hilo principal. No lo desarrollamos aquí, pero reconocerás el problema.

  1. El compilador de React y los componentes de servidor

Dos evoluciones importantes que conviene conocer para no confundirse al leer código actual, sin desarrollarlas.

El compilador de React. Analiza tus componentes durante la construcción y inserta automáticamente las memoizaciones que hoy se escriben a mano con useMemo, useCallback y React.memo. Si funciona bien, el apartado 18 se convierte en una anécdota histórica: escribes código simple y el compilador lo optimiza, que es exactamente el enfoque de la familia 3 de 10-01 aplicado a un framework de DOM virtual. La condición para que funcione es que tus componentes sean puros —lo que se pedía en el apartado 4—, con lo que la disciplina que estás aprendiendo ahora es lo que permite la optimización de mañana.

Los componentes de servidor. Componentes que se ejecutan solo en el servidor y envían al navegador el resultado, no su código. Ventajas: cero JavaScript descargado para las partes que no son interactivas, y acceso directo a la base de datos sin pasar por una API. Implican una distinción nueva entre componentes de servidor y de cliente, con reglas propias sobre qué puede hacer cada uno, y en la práctica requieren un meta-framework como Next.js. Es un tema grande y en movimiento; lo justo aquí es saber que existe y que resuelve el problema del peso descargado, que 10-06 retomará al hablar de renderizado en servidor.

  1. Cargar datos con useEffect y sus tres problemas

Este es el apartado más útil de la lección para el mundo real. La forma «obvia» de cargar datos es esta, y tiene tres fallos serios:

// ❌ La versión ingenua, con tres problemas
function ListaRemota({ responsable }) {
  const [tareas, setTareas] = useState([]);
  const [cargando, setCargando] = useState(true);

  useEffect(() => {
    setCargando(true);
    listarTareas({ responsable })
      .then((datos) => { setTareas(datos); setCargando(false); });
  }, [responsable]);

  if (cargando) return <p>Cargando…</p>;
  return <ListaTareas tareas={tareas} />;
}

Problema 1 · Condición de carrera. Marta cambia el filtro de «Iván» a «Lucía» rápidamente. Se lanzan dos peticiones. La de Iván tarda 900 ms; la de Lucía, 200 ms. La de Lucía vuelve primero y pinta sus tareas; después vuelve la de Iván y sobreescribe. La pantalla muestra las tareas de Iván con el filtro en «Lucía». Es un fallo real, intermitente y muy difícil de reproducir.

Problema 2 · Sin cancelación. Si el componente se desmonta mientras la petición vuela, se llama a setTareas sobre un componente que ya no existe: trabajo desperdiciado y, con recursos pesados, una fuga de las de 09-03.

Problema 3 · Doble ejecución en modo estricto. En desarrollo, <StrictMode> monta, desmonta y vuelve a montar cada componente a propósito. Verás dos peticiones en la pestaña Network. No es un fallo de React: es un detector de efectos que no limpian bien. Si tu efecto es correcto, la doble ejecución es inocua.

La versión correcta usa lo que ya sabes de 07-03:

// ✅ Con cancelación y protección contra carreras
function ListaRemota({ responsable }) {
  const [estado, setEstado] = useState({ fase: 'cargando', tareas: [], error: null });

  useEffect(() => {
    const controlador = new AbortController();
    setEstado((e) => ({ ...e, fase: 'cargando' }));

    listarTareas({ responsable, signal: controlador.signal })
      .then((tareas) => setEstado({ fase: 'listo', tareas, error: null }))
      .catch((error) => {
        if (error.name === 'AbortError') return;      // cancelación esperada: no es un fallo
        setEstado({ fase: 'error', tareas: [], error });
      });

    return () => controlador.abort();                  // ← limpieza: mata la petición anterior
  }, [responsable]);

  if (estado.fase === 'cargando') return <p role="status">Cargando tareas…</p>;
  if (estado.fase === 'error') return <p role="alert">No se han podido cargar: {estado.error.message}</p>;
  return <ListaTareas tareas={estado.tareas} />;
}

Las tres correcciones, una por problema:

  1. La carrera desaparece porque la limpieza aborta la petición anterior antes de lanzar la nueva. La respuesta de Iván nunca llega a then porque su promesa se rechaza con AbortError.
  2. La cancelación la da el mismo AbortController de 07-03, pasado a pedirJson como signal.
  3. La doble ejecución ya no molesta: la primera petición se aborta al desmontar y solo la segunda cuenta.

Y fíjate en el estado: una sola variable con fase, en lugar de tres booleanos independientes. Es la regla del estado imposible del apartado 11.

  1. Por qué en producción se usa una librería de datos

El código anterior es correcto y son veinte líneas. Ahora multiplícalo por las quince pantallas de una aplicación real y aparecen preguntas que ese código no responde:

  • Si dos componentes piden las mismas tareas a la vez, ¿se hacen dos peticiones?
  • Al volver a una pantalla visitada hace diez segundos, ¿se muestra lo que había mientras se refresca, o una pantalla de carga?
  • Cuando Marta vuelve a la pestaña tras una hora, ¿se refrescan los datos?
  • Al crear una tarea, ¿quién invalida la lista para que aparezca?
  • Si la red falla, ¿se reintenta? ¿Cuántas veces?
  • Con optimistic UI (07-03), ¿quién revierte si el servidor rechaza?

Responder a todo eso a mano, en cada pantalla, es escribir una caché de estado de servidor. Y eso ya existe: TanStack Query es la más usada en React (con equivalentes en Vue, Angular y Svelte). El concepto, que es lo que importa aquí:

// Presentado como concepto, no como tutorial
const { data: tareas, isPending, error } = useQuery({
  queryKey: ['tareas', responsable],           // identidad de esta consulta en la caché
  queryFn: ({ signal }) => listarTareas({ responsable, signal })
});

Lo que aporta, y que conecta directo con el apartado 15 de 10-01:

Aporta Qué resuelve
Caché por clave Dos componentes con la misma queryKey comparten una petición
Datos obsoletos mientras revalida Se muestra lo que había y se refresca por detrás: nada de pantallas de carga al volver atrás
Refresco automático Al enfocar la ventana, al reconectar, por intervalo
Reintentos Con retroceso exponencial, como tu conReintentos de 07-03
Cancelación Pasa el signal automáticamente
Invalidación Tras crear una tarea, se marca ['tareas'] como obsoleta y se recarga sola
Mutaciones optimistas Con reversión automática si falla

Lo importante no es la librería: es la idea de 10-01 de que el estado del servidor es un problema distinto y merece herramienta propia. En cuanto lo asumes, el estado que queda para React —o para Redux en 10-03— es mucho menor de lo que parecía.

  1. El ecosistema mínimo

Lo que necesita un proyecto React de verdad, más allá de React:

Pieza Opción habitual Qué ya sabes
Empaquetado Vite 09-05, tal cual
Enrutado React Router / TanStack Router / meta-framework La History API de 07-06 y tu enrutador.js
Datos remotos TanStack Query sobre tu pedirJson 07-02, 07-03
Estado global Redux Toolkit, Zustand, Jotai 10-03
Formularios React Hook Form, o FormData a mano 06-07
Calidad ESLint con react-hooks, Prettier 08-02
Pruebas Vitest o Jest + React Testing Library 08-03, 08-05
Extremo a extremo Cypress o Playwright 08-06

Merece la pena detenerse en las pruebas, porque aquí no aprendes nada nuevo: Testing Library funciona igual. En 08-05 escribiste pruebas que renderizaban la vista, consultaban por rol y simulaban clics. En React es idéntico:

import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';

test('al filtrar por Lucía solo queda su tarea', async () => {
  render(<App tareasIniciales={BACKLOG} />);

  expect(screen.getAllByRole('listitem')).toHaveLength(6);

  await userEvent.selectOptions(screen.getByLabelText(/responsable/i), 'Lucía');

  expect(screen.getAllByRole('listitem')).toHaveLength(1);
  expect(screen.getByText('Actualizar la web de reservas')).toBeInTheDocument();
});

Las consultas por rol, userEvent, la filosofía de probar lo que ve la usuaria y no los detalles internos: todo igual que en 08-05. Es una de las cosas que mejor se transfieren entre JavaScript puro y cualquier framework, y una razón más para haber aprendido primero lo de abajo.

  1. Nómada Tareas en React: la lista completa

Aquí está la reimplementación completa de la pantalla objetivo: la lista de tareas, con filtro por responsable y botón de marcar como hecha. Cuatro ficheros de componentes, dos hooks y un fichero de dominio en JavaScript puro.

Primero el dominio, que no es React y no cambiaría al cambiar de framework:

// src/dominio/reglas.js — JavaScript puro, sin dependencias
export const SIGUIENTE = Object.freeze({
  pendiente: 'en-curso',
  'en-curso': 'hecha',
  hecha: null
});

export const ETIQUETA = Object.freeze({
  pendiente: 'Empezar',
  'en-curso': 'Marcar hecha',
  hecha: 'Hecha'
});

export const PESOS = Object.freeze({ alta: 3, media: 2, baja: 1 });

export const HOY = '2026-09-20';

/** R10: vencida = fecha límite pasada y estado distinto de 'hecha'. */
export function estaVencida(tarea, hoy = HOY) {
  return tarea.estado !== 'hecha' && tarea.fechaLimite < hoy;
}

export function horasAbiertas(tareas) {
  return tareas.filter((t) => t.estado !== 'hecha')
               .reduce((suma, t) => suma + t.horasEstimadas, 0);
}

Ahora la tarjeta:

// src/componentes/TarjetaTarea.jsx
import { memo } from 'react';
import { SIGUIENTE, ETIQUETA, estaVencida } from '../dominio/reglas.js';

export const TarjetaTarea = memo(function TarjetaTarea({ tarea, alAvanzar }) {
  const vencida = estaVencida(tarea);
  const siguiente = SIGUIENTE[tarea.estado];

  return (
    <li
      className={`tarea tarea--${tarea.prioridad}
                  ${tarea.estado === 'hecha' ? 'tarea--hecha' : ''}
                  ${vencida ? 'tarea--vencida' : ''}`}
      data-id={tarea.id}
    >
      <h3 className="tarea__titulo">{tarea.titulo}</h3>

      <p className="tarea__meta">
        {tarea.responsable ?? 'sin asignar'} · {tarea.horasEstimadas} h
        {vencida && <span className="tarea__aviso"> · ⚠ vencida</span>}
      </p>

      <ul className="tarea__etiquetas">
        {tarea.etiquetas.map((e) => (
          <li key={e} className="etiqueta">{e}</li>
        ))}
      </ul>

      <button
        type="button"
        disabled={siguiente === null}
        onClick={() => alAvanzar(tarea.id, siguiente)}
        aria-label={`${ETIQUETA[tarea.estado]}: ${tarea.titulo}`}
      >
        {ETIQUETA[tarea.estado]}
      </button>
    </li>
  );
});

Puntos a subrayar:

  • memo hace que la tarjeta solo se vuelva a renderizar si sus props cambian por referencia. Aquí sí tiene sentido, porque useTablero devuelve el mismo objeto tarea para las que no cambian (gracias al map inmutable del apartado 19) y alAvanzar es estable gracias a useCallback. Sin esas dos condiciones, memo no serviría de nada.
  • data-id se conserva. No lo necesita React —para eso está la key—, pero lo necesitan tus pruebas de Cypress de 08-06 y hace que el DOM siga siendo legible.
  • La lista de etiquetas tiene su propia key: el texto de la etiqueta, que es único dentro de la tarea por la R9 (minúsculas y sin duplicados). Esa regla, que escribiste por limpieza de datos, resulta ser justo lo que hace válida la clave.
  • El botón se deshabilita cuando SIGUIENTE[estado] es null, que es la R6 aplicada en la vista.

El filtro:

// src/componentes/FiltroResponsable.jsx
export function FiltroResponsable({ responsables, valor, alCambiar }) {
  return (
    <label className="filtro" htmlFor="filtro-responsable">
      Responsable:
      <select
        id="filtro-responsable"
        value={valor ?? ''}
        onChange={(e) => alCambiar(e.target.value || null)}
      >
        <option value="">Todos</option>
        {responsables.map((nombre) => (
          <option key={nombre} value={nombre}>{nombre}</option>
        ))}
      </select>
    </label>
  );
}

Detalle importante: value={valor ?? ''} y e.target.value || null. El estado usa null para «sin filtro» —coherente con la R8, que prohíbe la cadena vacía— pero el DOM solo entiende cadenas. La conversión se hace en la frontera, en los dos sentidos.

La lista:

// src/componentes/ListaTareas.jsx
import { TarjetaTarea } from './TarjetaTarea.jsx';

export function ListaTareas({ tareas, alAvanzar }) {
  if (tareas.length === 0) {
    return <p className="lista-vacia">Ninguna tarea coincide con el filtro.</p>;
  }

  return (
    <ul className="lista-tareas">
      {tareas.map((tarea) => (
        <TarjetaTarea key={tarea.id} tarea={tarea} alAvanzar={alAvanzar} />
      ))}
    </ul>
  );
}

Y la aplicación que lo junta todo:

// src/App.jsx
import { useTablero } from './hooks/useTablero.js';
import { useFiltroResponsable } from './hooks/useFiltroResponsable.js';
import { FiltroResponsable } from './componentes/FiltroResponsable.jsx';
import { ListaTareas } from './componentes/ListaTareas.jsx';
import { horasAbiertas } from './dominio/reglas.js';

export default function App({ tareasIniciales }) {
  const { tareas, cambiarEstado, resumen } = useTablero(tareasIniciales);
  const { responsable, setResponsable, responsables, visibles } = useFiltroResponsable(tareas);

  return (
    <main className="tablero">
      <header className="tablero__cabecera">
        <h1>Nómada Tareas</h1>
        <FiltroResponsable
          responsables={responsables}
          valor={responsable}
          alCambiar={setResponsable}
        />
        <p className="tablero__resumen">
          {visibles.length} de {resumen.total} tareas · {horasAbiertas(visibles)} h abiertas
        </p>
      </header>

      <ListaTareas tareas={visibles} alAvanzar={cambiarEstado} />
    </main>
  );
}

Comprueba los números canónicos: sin filtro, 6 de 6 tareas y 45 h abiertas. Con «Iván», 3 de 6 y 25 h. Con «Lucía», 1 de 6 y 14 h. Con «Marta», 2 de 6 y 6 h abiertas (la de inventario está hecha y no suma). Y al pulsar «Empezar» en Presupuesto de la carpintería, la tarjeta cambia de estado, deja de estar vencida en cuanto llegue a hecha, y las horas abiertas bajan a 40 — sin que nadie haya escrito una sola línea que actualice el resumen. Ese es el modelo declarativo funcionando.

  1. Comparación lado a lado con tablero-vista.js

Ahora la comparación honesta con lo que ya tenías.

Concepto JavaScript puro (Nómada Tareas) React
Describir la pantalla pintarTarjeta(tarea, existente, hoy) + <template> <TarjetaTarea tarea={t}/>
Identidad en listas data-id + reconciliar() (20 líneas escritas por ti) key={t.id} (una propiedad)
Volver a pintar Llamar a render() a mano Llamar a setEstado; el renderizado es automático
Estado local de un trozo No hay sitio natural: dataset o un Map externo useState dentro del componente
Escuchar eventos Delegación con closest('[data-accion]') onClick en cada elemento (React delega por dentro)
Comunicar hacia arriba CustomEvent + EVENTOS Prop de función
Limpieza destruir() + AbortController, invocado a mano return del efecto, invocado por React
Caché de derivados Caché por versión con #version useMemo con dependencias
Estilos Clases globales en estilos.css Clases globales, módulos CSS o similares
Reutilizar lógica con estado Clases o closures Hooks personalizados

Y la comparación cuantitativa de la misma pantalla —lista con filtro y botón de avanzar—, con los ficheros de este apartado frente a los equivalentes de tu proyecto:

Métrica JavaScript puro React
Líneas de código de la vista ~210 (dom.js + tarjeta.js + tablero-vista.js + controlador.js + <template>) ~130 (4 componentes + 2 hooks)
Líneas de infraestructura escritas por ti ~60 (reconciliar, crearElemento, $, $$, delegación) 0
Líneas de dominio (reutilizables en cualquier caso) ~40 ~40 (las mismas)
Dependencias de producción 0 2 (react, react-dom)
JavaScript descargado (comprimido, aprox.) 58,3 kB toda la aplicación +45 kB solo el motor
Ficheros que tocar para añadir un dato a la tarjeta 2 (<template> y tarjeta.js) 1 (TarjetaTarea.jsx)
Conceptos que hay que conocer para leerlo DOM, eventos, ES modules Todo lo anterior más JSX, hooks, reglas de hooks, reconciliación

Qué se ha ganado, sin adornos:

  • Desaparecen 60 líneas de infraestructura que además hay que mantener y probar.
  • El estado local por componente tiene por fin dónde vivir.
  • La limpieza está garantizada, no confiada a la memoria.
  • Cada componente es una unidad legible, con su contrato en la primera línea.
  • La reconciliación funciona para árboles anidados, no solo para listas planas de hijos directos — que es donde tu reconciliar se quedaba corto.

Qué se ha perdido, con la misma franqueza:

  • 45 kB de motor descargado antes de ver nada. En una aplicación de esta escala, es más que toda la lógica.
  • Un paso de compilación obligatorio: sin compilador no hay JSX.
  • Conceptos nuevos que hay que aprender y depurar: por qué se ejecuta dos veces, por qué esta dependencia cambia siempre, por qué esto no se actualiza.
  • Control fino sobre el DOM: tu virtualización de 09-04, tus lotes de escritura y tus mediciones con requestAnimationFrame ahora hay que hacerlos a través de React, con ref y librerías, en lugar de directamente.
  • Independencia: el 60 % de este código solo vale dentro de React.

La conclusión honesta para Nómada Tareas: con seis tareas, una pantalla y un desarrollador, React no compensa. Con cinco pantallas, cuatro personas y treinta componentes reutilizables, compensa claramente. Y ese cambio de respuesta según el contexto —no un ganador absoluto— es exactamente lo que 10-06 va a formalizar.

Errores Comunes y Consejos

Usar el índice del array como key. Funciona hasta el día que se filtra, se reordena o se elimina por el medio; entonces produce fallos sutiles de foco y de estado. Si tienes id, úsalo. Es el mismo error que hubieras cometido usando la posición en tu reconciliar.

Mutar el estado. tareas.push(nueva) o tarea.estado = 'hecha' no provocan renderizado, porque la referencia no cambia. Usa spread y map como en 04-07. Si el estado es un objeto anidado profundo, es señal de que está mal modelado.

Esperar que el estado cambie inmediatamente tras setEstado. No cambia: la variable actual es una constante capturada en esta ejecución. El valor nuevo llega en el siguiente renderizado. Si necesitas el valor anterior para calcular el nuevo, usa la forma de función: setContador((n) => n + 1).

Poner objetos literales en el array de dependencias. [{ responsable }] es un objeto nuevo en cada render y el efecto se dispara siempre. Depende de primitivos: [responsable].

Silenciar exhaustive-deps. Es la regla que evita que un efecto lea valores obsoletos. Cuando molesta, casi siempre el problema es el diseño del efecto. Deshabilitarla convierte un aviso en un fallo intermitente.

Usar useEffect para derivar estado. Dos renderizados, dos fuentes de verdad y un fotograma con el valor viejo. Calcula durante el renderizado; memoiza solo si mides que duele.

Memoizar todo «por si acaso». useMemo y useCallback tienen coste y ensucian el código. Sin React.memo en el destinatario, useCallback no sirve de nada. Mide con el Profiler antes, como en 09-01.

Asustarse por la doble ejecución en desarrollo. <StrictMode> monta y desmonta a propósito para destapar efectos que no limpian. Si tu efecto está bien escrito, es inocuo. Si te molesta de verdad, es que hay algo que arreglar.

Cargar datos sin AbortController. Es la condición de carrera del apartado 24, y produce fallos que solo aparecen con la red lenta. Todo lo de 07-03 sigue siendo tan necesario como antes.

Consejo: mantén la lógica de negocio fuera de los componentes. dominio/reglas.js es JavaScript puro y se prueba con Jest sin renderizar nada. Es la disciplina de 10-01 y es lo que hace reversible la elección de framework.

Consejo: empieza sin memoización y sin librerías. useState, props y cálculo directo llegan mucho más lejos de lo que parece. Añade complejidad cuando el Profiler o el dolor real lo justifiquen.

Ejercicios

Ejercicio 1 · Añadir el resumen por responsable

Partiendo del código del apartado 27, añade un componente <ResumenPorResponsable> que muestre, para cada persona del equipo, cuántas tareas abiertas tiene y cuántas horas suman. Debe respetar los números canónicos: Iván 3 tareas / 25 h, Lucía 1 / 14 h, Marta 1 / 6 h.

Requisitos:

  • El cálculo se hace durante el renderizado, no en un efecto.
  • Usa Object.groupBy o reduce, como en 04-05.
  • El componente recibe las tareas por props y no tiene estado propio.
  • Añade una key correcta a la lista y justifica tu elección.

Pregunta adicional: ¿debería este componente mostrar el total de todas las tareas o solo el de las visibles tras el filtro? Justifica.

Ejercicio 2 · Cazar el error de la condición de carrera

Este componente tiene tres fallos distintos. Encuéntralos, explica cuándo se manifiesta cada uno y escribe la versión corregida.

function DetalleTarea({ id }) {
  const [tarea, setTarea] = useState(null);
  const [comentarios, setComentarios] = useState([]);

  useEffect(() => {
    fetch(`/api/tareas/${id}`)
      .then((r) => r.json())
      .then(setTarea);
  }, []);

  useEffect(() => {
    if (tarea) {
      setComentarios(tarea.comentarios.filter((c) => !c.borrado));
    }
  }, [tarea]);

  return (
    <article>
      <h2>{tarea?.titulo}</h2>
      {comentarios.map((c, i) => <p key={i}>{c.texto}</p>)}
    </article>
  );
}

Ejercicio 3 · El hook useDebounce y el buscador

En 09-02 escribiste una función debounce en js/util/tiempo.js para que el buscador no filtrara en cada tecla. Ahora hazlo a la manera de React:

  1. Escribe un hook useDebounce(valor, retardo) que devuelva el valor con retardo, usando useState y useEffect con su limpieza.
  2. Úsalo en un componente <BuscadorTareas> con un <input> controlado que filtre la lista por título.
  3. Explica por qué el <input> debe usar el valor inmediato y el filtro el valor retardado, y qué pasaría si se usara el retardado en los dos sitios.
  4. ¿Por qué este hook necesita obligatoriamente la función de limpieza? ¿Qué ocurriría sin ella?

Soluciones

Solución 1

// src/componentes/ResumenPorResponsable.jsx
export function ResumenPorResponsable({ tareas }) {
  // Cálculo durante el renderizado: sin efectos, sin estado.
  const porPersona = tareas
    .filter((t) => t.estado !== 'hecha')          // solo abiertas
    .reduce((acc, t) => {
      const nombre = t.responsable ?? 'sin asignar';   // R8: null, nunca ''
      const previo = acc[nombre] ?? { tareas: 0, horas: 0 };
      acc[nombre] = {
        tareas: previo.tareas + 1,
        horas: previo.horas + t.horasEstimadas
      };
      return acc;
    }, {});

  const filas = Object.entries(porPersona).sort(([a], [b]) => a.localeCompare(b, 'es'));

  return (
    <ul className="resumen-responsables">
      {filas.map(([nombre, { tareas: n, horas }]) => (
        <li key={nombre}>
          <strong>{nombre}</strong>: {n} {n === 1 ? 'tarea' : 'tareas'} · {horas} h
        </li>
      ))}
    </ul>
  );
}

La key: el nombre del responsable. Es única dentro de esta lista (es la clave del objeto agrupado) y es estable: si Iván pasa de tres a dos tareas, su fila conserva el mismo nodo y no parpadea. El índice sería mala idea porque la lista se reordena al aparecer o desaparecer personas al filtrar.

Con el backlog canónico, sin filtro: Iván 3 tareas / 25 h, Lucía 1 / 14 h, Marta 1 / 6 h. Total 5 tareas abiertas y 45 h, que cuadra (la sexta, Inventario de tintas de Marta, está hecha y no cuenta).

¿Totales o visibles? Debe recibir todas las tareas, no las visibles. El motivo es de significado: el resumen por responsable existe para responder «¿cómo está repartida la carga del taller?», y esa pregunta no depende de qué esté mirando Marta ahora mismo. Si recibiera las visibles, al filtrar por Iván el resumen mostraría solo a Iván, lo cual es tautológico e inútil. Es un buen ejemplo de que qué props pasas es una decisión de producto, no técnica:

<ResumenPorResponsable tareas={tareas} />       {/* todas */}
<ListaTareas tareas={visibles} … />             {/* filtradas */}

Solución 2

Fallo 1 · Dependencia que falta ([] en lugar de [id]). Se manifiesta cuando el usuario navega de una tarea a otra sin desmontar el componente: se sigue mostrando la primera tarea para siempre, porque el efecto no se vuelve a ejecutar. Es exactamente lo que detecta exhaustive-deps.

Fallo 2 · Sin cancelación ni control de errores. Dos manifestaciones: la condición de carrera del apartado 24 si se cambia de tarea rápido con la red lenta, y un fallo silencioso si fetch rechaza o si el servidor responde 404 (recuerda de 07-02 que fetch no rechaza ante un 404: r.json() fallará al intentar analizar la respuesta).

Fallo 3 · Estado derivado con un efecto (el segundo useEffect) y key por índice en los comentarios. comentarios se deduce de tarea, así que se calcula durante el renderizado. Y usar el índice como clave hace que, al borrarse un comentario del medio, todos los siguientes hereden nodos ajenos.

Versión corregida:

function DetalleTarea({ id }) {
  const [estado, setEstado] = useState({ fase: 'cargando', tarea: null, error: null });

  useEffect(() => {
    const controlador = new AbortController();
    setEstado({ fase: 'cargando', tarea: null, error: null });

    pedirJson(`/api/tareas/${id}`, { signal: controlador.signal })   // 07-03: lanza ErrorDeApi
      .then((tarea) => setEstado({ fase: 'listo', tarea, error: null }))
      .catch((error) => {
        if (error.name === 'AbortError') return;
        setEstado({ fase: 'error', tarea: null, error });
      });

    return () => controlador.abort();
  }, [id]);                                    // ← fallo 1 corregido

  if (estado.fase === 'cargando') return <p role="status">Cargando…</p>;
  if (estado.fase === 'error') return <p role="alert">{estado.error.message}</p>;

  // ← fallo 3 corregido: derivado durante el renderizado
  const comentarios = estado.tarea.comentarios.filter((c) => !c.borrado);

  return (
    <article>
      <h2>{estado.tarea.titulo}</h2>
      {comentarios.map((c) => <p key={c.id}>{c.texto}</p>)}   {/* clave estable */}
    </article>
  );
}

Nota adicional: pedirJson es tu función de 07-03, que ya comprueba response.ok y lanza ErrorDeApi. Reutilizarla dentro de React sin adaptador es la mejor demostración de que la lógica que no depende del framework sobrevive al framework.

Solución 3

// src/hooks/useDebounce.js
import { useState, useEffect } from 'react';

/**
 * Devuelve `valor` con un retardo: solo se actualiza cuando `valor` deja
 * de cambiar durante `retardo` milisegundos.
 */
export function useDebounce(valor, retardo = 300) {
  const [retardado, setRetardado] = useState(valor);

  useEffect(() => {
    const id = setTimeout(() => setRetardado(valor), retardo);
    return () => clearTimeout(id);      // ← imprescindible
  }, [valor, retardo]);

  return retardado;
}
// src/componentes/BuscadorTareas.jsx
import { useState, useMemo } from 'react';
import { useDebounce } from '../hooks/useDebounce.js';
import { ListaTareas } from './ListaTareas.jsx';

export function BuscadorTareas({ tareas, alAvanzar }) {
  const [texto, setTexto] = useState('');
  const textoRetardado = useDebounce(texto, 300);

  const visibles = useMemo(() => {
    const t = textoRetardado.trim().toLowerCase();
    return t === '' ? tareas : tareas.filter((x) => x.titulo.toLowerCase().includes(t));
  }, [tareas, textoRetardado]);

  return (
    <>
      <label htmlFor="buscar">Buscar tareas</label>
      <input
        id="buscar"
        type="search"
        value={texto}                                  // ← valor INMEDIATO
        onChange={(e) => setTexto(e.target.value)}
      />
      <ListaTareas tareas={visibles} alAvanzar={alAvanzar} />
    </>
  );
}

Punto 3 · Por qué el <input> usa el valor inmediato. Un campo controlado muestra exactamente lo que dice su value. Si le pasaras textoRetardado, las letras aparecerían 300 ms después de pulsarlas: la sensación sería la de un teclado roto, y borrar rápido produciría saltos del cursor. Es exactamente el problema que 09-02 describía al distinguir entre lo que se ve (debe ser inmediato) y lo que cuesta (puede retrasarse). El filtro, que recorre las tareas, es lo que se retrasa.

Punto 4 · Por qué es imprescindible la limpieza. Sin clearTimeout, cada pulsación programaría un temporizador nuevo sin cancelar el anterior. Escribir «serigrafía» (10 letras) dejaría diez temporizadores vivos, y los diez se dispararían: el filtro se ejecutaría diez veces con diez valores distintos, en orden, y el efecto de retardo desaparecería por completo. Además, si el componente se desmonta mientras hay temporizadores pendientes, se llamaría a setRetardado sobre un componente muerto. Es, punto por punto, el problema 4 de 10-01 y la razón de tu destruir() de 09-03 — solo que aquí React garantiza la llamada.

Conclusión

Has visto React entero en su versión moderna y, más importante, has visto qué problema resuelve cada pieza, porque ya habías resuelto todos esos problemas a mano.

Sabes que JSX no es HTML sino azúcar sintáctico que se compila a llamadas de función que devuelven objetos descriptores, y que de ahí salen todas sus reglas: un solo elemento raíz porque return devuelve un valor, expresiones y no sentencias entre llaves, className porque class es reservada, valores que no se pintan —con la trampa del 0 en &&— y escapado automático que es tu textContent de siempre. Sabes que un componente es una función pura de props a descripción, con la mayúscula inicial como requisito del compilador, y que se compone pasando datos, funciones y children.

Has cerrado el círculo de la key: React empareja los elementos de una lista por clave y, sin ella, por posición — con el resultado exacto que analizaste en 06-06 cuando reconciliar no usaba data-id: cinco nodos que cambian de dato, el foco perdido y el estado interno atribuido a la tarea equivocada. La key de React es tu data-id, y las tres reglas son las mismas: estable, única entre hermanas y nunca el índice si la lista se mueve.

Sabes cómo funciona useState: el valor inicial solo cuenta una vez —con la forma perezosa para cálculos caros—, la variable no cambia en la ejecución actual porque es un closure de 03-04, las actualizaciones se agrupan, la forma de función encadena, y la comparación por referencia con Object.is es la razón exacta por la que el estado se trata como inmutable, con el { ...tarea, estado: 'hecha' } de 04-07 y el map que conserva las referencias de lo que no cambia. Y sabes que la forma del estado importa más que el estado: no guardes lo que puedes calcular, guarda identificadores en vez de objetos, y modela los estados de forma que los imposibles no se puedan expresar.

Entiendes useEffect con precisión: sirve para sincronizar con sistemas externos a React, y no para derivar estado, ni para reaccionar a clics, ni para transformar datos. Conoces el array de dependencias con su comparación por referencia y la trampa de los objetos literales, y sabes que la función de limpieza es tu destruir(), con el contenido idéntico y la diferencia decisiva de que la llamada está garantizada. Sabes por qué derivar estado con efectos produce dos renderizados y dos fuentes de verdad, y por qué eso reintroduce dentro del framework justo el problema que el framework venía a resolver.

Distingues useMemo, useCallback y useRef —resultado, función y caja mutable—, sabes que useMemo es tu caché por versión de 09-02 con las dependencias haciendo de contador, y tienes el aviso de 09-01 con nombres y procedimiento: memoizar no es gratis, useCallback sin React.memo en el destinatario no sirve, y el camino correcto es Profiler → componente caro → prop que cambia → optimización mínima → medir otra vez. Sabes extraer hooks personalizadosuseTablero y useFiltroResponsable— y que un hook comparte lógica pero no estado, con las dos reglas de los hooks y su motivo real: React identifica el estado por orden de llamada.

Conoces la diferencia entre formularios controlados y no controlados, con el criterio para elegir y el recordatorio de que FormData, Object.fromEntries y la validación nativa de 06-07 siguen siendo la mejor herramienta para un alta sencilla. Entiendes el DOM virtual con sus tres reglas heurísticas y su intercambio explícito —más trabajo en JavaScript, menos en el DOM, con coste proporcional a cuánto hay y no a cuánto cambia—, y sabes que existen el compilador de React y los componentes de servidor, y qué problema ataca cada uno. Y sabes cargar datos con sus tres problemas reales —condición de carrera, falta de cancelación y doble ejecución en modo estricto— resueltos con el AbortController de 07-03 y un estado con fase, además de por qué en producción se usa una caché de estado de servidor como TanStack Query.

Finalmente, has visto la pantalla objetivo escrita en React: TarjetaTarea, FiltroResponsable, ListaTareas, dos hooks y un fichero de dominio en JavaScript puro que no cambiaría al cambiar de framework. Con la comparación honesta frente a tablero-vista.js: unas 130 líneas frente a 210, cero líneas de infraestructura frente a las 60 de reconciliar, $, $$ y la delegación — a cambio de 45 kB de motor, un paso de compilación obligatorio, conceptos nuevos que depurar y menos control directo sobre el DOM. Para seis tareas y una persona, no compensa. Para cinco pantallas y cuatro personas, compensa.

Hay un problema que esta lección ha dejado deliberadamente fuera. useFiltroResponsable funciona porque el filtro vive en App y desde ahí baja a los dos sitios que lo necesitan. ¿Qué pasa cuando lo necesitan también el enrutador, el resumen de la cabecera, el informe y un componente enterrado a seis niveles de profundidad? Pasar la prop por seis componentes que no la usan tiene nombre —prop drilling— y es el punto de partida de la siguiente lección: Gestión de Estado con Redux.

Curso de JavaScript: De Principiante a Avanzado

Módulo 1: Introducción a JavaScript

Módulo 2: Estructuras de Control

Módulo 3: Funciones

Módulo 4: Objetos y Arrays

Módulo 5: Objetos y Funciones Avanzadas

Módulo 6: El Modelo de Objetos del Documento (DOM)

Módulo 7: APIs del Navegador y Temas Avanzados

Módulo 8: Pruebas y Depuración

Módulo 9: Rendimiento y Optimización

Módulo 10: Frameworks y Librerías de JavaScript

Módulo 11: Proyecto Final

© Copyright 2026. Todos los derechos reservados