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
- Qué es React y qué no es
- Poner en marcha un proyecto
- JSX: qué es y en qué se convierte
- Componentes de función
propsy composición- Renderizado de listas y la
key key: el mismo problema que resolviste en 06-06- Renderizado condicional
- Estado con
useState - Por qué el estado se trata como inmutable
- La forma del estado importa más que el estado
- Eventos en React y la diferencia con
addEventListener useEffect: qué es y qué no es- El array de dependencias
- La función de limpieza: tu
destruir() - El error de derivar estado con efectos
useMemo,useCallbackyuseRef- No optimices sin medir
- Hooks personalizados:
useTableroyuseFiltroResponsable - Las reglas de los hooks y por qué existen
- Formularios controlados y no controlados
- El DOM virtual y la reconciliación, de verdad
- El compilador de React y los componentes de servidor
- Cargar datos con
useEffecty sus tres problemas - Por qué en producción se usa una librería de datos
- El ecosistema mínimo
- Nómada Tareas en React: la lista completa
- Comparación lado a lado con
tablero-vista.js - Errores Comunes y Consejos
- Ejercicios
- Conclusión
- 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.
- Poner en marcha un proyecto
Con Vite, que ya usas desde 09-05, un proyecto React nuevo se crea así:
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.
- JSX: qué es y en qué se convierte
Esto es JSX:
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 <>…</>:
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 sí 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.
- 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:
- 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. - 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
fdeUI = 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.
props y composición
props y composiciónLas 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.
- Renderizado de listas y la
key
keyUna 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.
key: el mismo problema que resolviste en 06-06
key: el mismo problema que resolviste en 06-06Este 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.
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.
- 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:
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:
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.
- Estado con
useState
useStateHasta 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:
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 renderEsta 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 24 · 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.
- 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 renderizaLa 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:
mapdevuelve 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 elspreadde 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.
- 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 manoSi 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á bienSí: 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.
- Eventos en React y la diferencia con
addEventListener
addEventListenerEn React los manejadores se declaran como props:
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.
useEffect: qué es y qué no es
useEffect: qué es y qué no esuseEffect 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.
useEffectsirve 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.
- 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 filtroLa 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:
- Depender de valores primitivos:
[filtros.responsable, filtros.texto]en lugar de[filtros]. Casi siempre es lo correcto. - Mover la creación del objeto fuera del componente si es constante.
- Memoizar el objeto con
useMemoen 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.
- La función de limpieza: tu
destruir()
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».
- 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:
- 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.
- Hay dos fuentes de verdad.
tareasyhorasAbiertaspueden desincronizarse: es el problema 1 de 10-01 reinventado dentro del framework que venía a resolverlo. - 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.
useMemo, useCallback y useRef
useMemo, useCallback y useRefTres 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 fueraFí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).
- 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:
- 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.
- Encuentra el componente caro. Casi siempre es uno: una lista larga, un gráfico, un cálculo pesado.
- 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í
useCallbacksirve. - 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.
- Hooks personalizados:
useTablero y useFiltroResponsable
useTablero y useFiltroResponsableUn 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:
- Devuelven un objeto, no un array. Los arrays son cómodos cuando hay dos valores (como
useState); con cinco, un objeto es autodocumentado. - Las reglas de negocio están fuera.
SIGUIENTEy las R1–R10 viven endominio/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. - Cada llamada al hook crea su propio estado. Dos componentes que usen
useFiltroResponsabletienen dos filtros independientes. Un hook no comparte estado: comparte lógica. Confundir esto es el malentendido número uno sobre los hooks. useMemoaquí sí tiene un motivo, y no es el rendimiento del cálculo: es mantener estable la referencia devisiblesyresponsablespara que los componentes hijos memoizados no se repinten sin motivo.new Set(...)de 04-05 elimina duplicados yfilter(Boolean)descarta losnullde la R8: dos utilidades de JavaScript puro que se usan igual dentro de React.
- Las reglas de los hooks y por qué existen
Dos reglas, y una explicación que casi nunca se da:
- Solo se llaman en el nivel superior de un componente o de otro hook. Nunca dentro de un
if, un bucle, untryo una función anidada. - 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.
- 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.
- 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.
- 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.
- Cargar datos con
useEffect y sus tres problemas
useEffect y sus tres problemasEste 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:
- La carrera desaparece porque la limpieza aborta la petición anterior antes de lanzar la nueva. La respuesta de Iván nunca llega a
thenporque su promesa se rechaza conAbortError. - La cancelación la da el mismo
AbortControllerde 07-03, pasado apedirJsoncomosignal. - 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.
- 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.
- 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.
- 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:
memohace que la tarjeta solo se vuelva a renderizar si sus props cambian por referencia. Aquí sí tiene sentido, porqueuseTablerodevuelve el mismo objeto tarea para las que no cambian (gracias almapinmutable del apartado 19) yalAvanzares estable gracias auseCallback. Sin esas dos condiciones,memono serviría de nada.data-idse conserva. No lo necesita React —para eso está lakey—, 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]esnull, 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.
- Comparación lado a lado con
tablero-vista.js
tablero-vista.jsAhora 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
reconciliarse 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
requestAnimationFrameahora hay que hacerlos a través de React, conrefy 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.groupByoreduce, como en 04-05. - El componente recibe las tareas por props y no tiene estado propio.
- Añade una
keycorrecta 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:
- Escribe un hook
useDebounce(valor, retardo)que devuelva el valor con retardo, usandouseStateyuseEffectcon su limpieza. - Úsalo en un componente
<BuscadorTareas>con un<input>controlado que filtre la lista por título. - 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. - ¿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 personalizados —useTablero 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
- ¿Qué es JavaScript?
- Configuración de tu Entorno de Desarrollo
- Tu Primer Programa en JavaScript
- Sintaxis y Conceptos Básicos de JavaScript
- Variables y Tipos de Datos
- Operadores Básicos
- Conversión de Tipos y Comparaciones
- El Proyecto del Curso: Nómada Tareas
Módulo 2: Estructuras de Control
- Sentencias Condicionales
- Bucles: for, while, do-while
- Sentencias Switch
- Control del Flujo: break, continue y Bucles Anidados
- Manejo de Errores con try-catch
Módulo 3: Funciones
- Definición y Llamada de Funciones
- Expresiones de Función y Funciones Flecha
- Parámetros y Valores de Retorno
- Ámbito y Closures
- Hoisting y el Contexto de Ejecución
- Funciones de Orden Superior
- Recursividad
Módulo 4: Objetos y Arrays
- Introducción a los Objetos
- Métodos de Objeto y la Palabra Clave
this - Arrays: Conceptos Básicos y Métodos
- Iteración sobre Arrays
- Buscar, Ordenar y Agregar Datos: find, sort y reduce
- Desestructuración de Arrays
- Desestructuración de Objetos, Spread y Rest
- JSON y Copias de Objetos
Módulo 5: Objetos y Funciones Avanzadas
- Prototipos y Herencia
- Clases y Programación Orientada a Objetos
- Encapsulación: Getters, Setters y Campos Privados
- Módulos e Importación/Exportación
- JavaScript Asíncrono: Callbacks
- Promesas y Async/Await
- El Bucle de Eventos y la Cola de Microtareas
- Iteradores y Generadores
Módulo 6: El Modelo de Objetos del Documento (DOM)
- Introducción al DOM
- Selección y Manipulación de Elementos del DOM
- Manejo de Eventos
- Propagación, Delegación y Eventos Personalizados
- Creación y Eliminación de Elementos del DOM
- Renderizado de Listas y Plantillas HTML
- Manejo y Validación de Formularios
Módulo 7: APIs del Navegador y Temas Avanzados
- Almacenamiento Local y de Sesión
- Fetch API y AJAX
- Peticiones Robustas: Errores, Timeouts y AbortController
- WebSockets
- Service Workers y Aplicaciones Web Progresivas (PWAs)
- APIs del Navegador Esenciales
- Introducción a WebAssembly
Módulo 8: Pruebas y Depuración
- Depuración de JavaScript
- Calidad de Código: ESLint, Prettier y Convenciones
- Pruebas Unitarias con Jest
- Dobles de Prueba: Mocks, Stubs y Spies
- Pruebas de Integración
- Pruebas de Extremo a Extremo con Cypress
Módulo 9: Rendimiento y Optimización
- Medir Antes de Optimizar: DevTools y Web Vitals
- Optimización del Rendimiento de JavaScript
- Gestión de Memoria
- Manipulación Eficiente del DOM
- Carga Perezosa y División de Código
Módulo 10: Frameworks y Librerías de JavaScript
- Por Qué Existen los Frameworks
- Introducción a React
- Gestión de Estado con Redux
- Conceptos Básicos de Vue.js
- Conceptos Básicos de Angular
- Elegir el Framework Adecuado
