Módulo 09: Efectos con useEffect
Objetivos del módulo
- Entender qué problema resuelve un efecto y distinguirlo del render y de los manejadores de eventos
- Escribir efectos con
useEffecty saber exactamente cuándo se ejecutan - Usar la lista de dependencias sin engañar a React: reactivos,
Object.is, clausuras viejas y bucles infinitos - Escribir limpiezas correctas para temporizadores, oyentes, conexiones y peticiones
- Pensar en el ciclo de vida de cada efecto, no en el del componente
- Interpretar la doble ejecución de Strict Mode y usarla a tu favor
- Pedir datos con
fetch, resolver las condiciones de carrera y cancelar conAbortController - Reconocer los efectos que sobran y sustituirlos por código más simple
- Separar lógica no reactiva con
useEffectEvent(React 19.2) y conocer la alternativa en versiones anteriores - Saber cuándo usar
useLayoutEffecty para qué existeuseInsertionEffect - Aprovechar las reglas de lint de los hooks en lugar de silenciarlas
1. Qué es un efecto y por qué existe
Hasta ahora, un componente hacía dos cosas: calcular qué hay que mostrar y responder a lo que hace el usuario. Lo primero ocurre durante el render, que debe ser puro, como viste en el módulo 03: mismas props y mismo estado, mismo resultado, sin tocar nada de fuera. Lo segundo ocurre en los manejadores de eventos, que sí pueden cambiar cosas.
Pero hay código que no encaja en ninguno de los dos sitios. Imagina un reloj que debe actualizarse cada segundo. Nadie pulsa nada, así que no hay evento. Y si lo pones en el cuerpo del componente, pasa esto:
import { useState } from 'react';
// ❌ MAL: arranca un temporizador nuevo cada vez que se renderiza
export default function Reloj() {
const [ahora, setAhora] = useState(() => new Date());
setInterval(() => setAhora(new Date()), 1000);
return <p className="tarjeta">Son las {ahora.toLocaleTimeString('es-ES')}</p>;
}
Cada render programa un temporizador nuevo; cada temporizador cambia el estado; cada cambio de estado provoca otro render. Hemos contado los temporizadores vivos en una compilación de producción:
tras 1 s: temporizadores vivos = 1
tras 2 s: temporizadores vivos = 2
tras 3 s: temporizadores vivos = 6
tras 4 s: temporizadores vivos = 11
tras 5 s: temporizadores vivos = 20
tras 6 s: temporizadores vivos = 30
Deberían ser uno, y el número no deja de crecer. El problema no es setInterval: es que el render no es el lugar para arrancar nada, porque React decide cuándo y cuántas veces renderiza.
📘 Concepto: Un efecto es un fragmento de código que React ejecuta después de actualizar la pantalla, y cuya función es sincronizar el componente con algo que está fuera de React. No lo provoca un clic concreto, sino el hecho de que el componente se ha mostrado con unos datos determinados.
Figura 1. Cada tipo de lógica tiene su sitio. La pregunta útil es por qué tiene que ejecutarse ese código.
Fíjate en la expresión “algo que está fuera de React”. Esos son los sistemas externos, y son la única razón de ser de un efecto:
| Sistema externo | Ejemplo en una aplicación de gestión |
|---|---|
| Temporizadores del navegador | Reloj, cuenta atrás de la sesión, refresco periódico |
Eventos de window o document | resize, online, keydown global, clic fuera de un menú |
| Conexiones | WebSocket de avisos, EventSource |
| Red | Cargar los datos que necesita una pantalla |
| Almacenamiento | localStorage |
| APIs del DOM sin equivalente en JSX | IntersectionObserver, reproducir un vídeo, dar el foco |
| Librerías que no son de React | Un mapa, un editor de texto o un gráfico con su propia API |
Con esto ya podemos escribir el reloj. useEffect es un hook que recibe la función que queremos ejecutar después de actualizar la pantalla:
function Reloj() {
const [ahora, setAhora] = useState(() => new Date());
useEffect(() => {
setInterval(() => setAhora(new Date()), 1000);
}, []);
return <p className="tarjeta">Son las {ahora.toLocaleTimeString('es-ES')}</p>;
}

Figura 2. El reloj funcionando: un solo temporizador, arrancado después del primer render.
Esta versión ya no multiplica los temporizadores, porque la lista vacía [] hace que el efecto se ejecute solo al aparecer el componente. Sin embargo, todavía tiene un fallo que veremos en la sección 4. Fíjate también en useState(() => new Date()): pasamos una función para que la fecha se calcule una sola vez y no en cada render, lo que evita otra impureza que el linter señalaría (sección 12).
Prepara el proyecto
Para probar los ejemplos crea un proyecto como en el módulo 01 y deja en marcha la API de ejemplo:
npm create vite@latest modulo-09 -- --template react-ts
cd modulo-09
npm install
npm run dev
Descarga recursos/empresa-api.zip, descomprímelo y arráncalo en otra terminal con node server.mjs. Quedará en http://localhost:3000/api.
Cada ejemplo completo de este módulo sustituye a src/App.tsx. Cuando un listado solo muestra una parte, el texto indica lo que falta. Borra src/App.css y sustituye src/index.css por estas reglas, que usan todos los ejemplos:
:root {
font-family: 'Segoe UI', system-ui, sans-serif;
color: #263238;
background: #f5f6fa;
}
body {
margin: 0;
padding: 1.25rem;
}
.tarjeta {
background: #fff;
border: 1px solid #c5cae9;
border-radius: 10px;
padding: 1rem 1.25rem;
max-width: 620px;
margin: 0 0 1rem;
}
.tarjeta h2 {
margin: 0 0 0.5rem;
font-size: 1.15rem;
color: #1a237e;
}
.fila {
display: flex;
gap: 0.5rem;
align-items: center;
flex-wrap: wrap;
margin-bottom: 0.75rem;
}
button,
input,
select {
font: inherit;
padding: 0.35rem 0.7rem;
border: 1px solid #3f51b5;
border-radius: 6px;
background: #fff;
color: #1a237e;
}
button {
cursor: pointer;
background: #e8eaf6;
}
button[aria-pressed='true'] {
background: #3f51b5;
color: #fff;
}
table {
border-collapse: collapse;
width: 100%;
}
th,
td {
text-align: left;
padding: 0.35rem 0.5rem;
border-bottom: 1px solid #e8eaf6;
}
.tenue {
color: #607d8b;
}
.error {
color: #c62828;
}
⚠️ Importante: La plantilla de Vite envuelve la aplicación en
<StrictMode>y eso hace que, en desarrollo, los efectos se ejecuten dos veces al montar. Es a propósito. Lo explica la sección 6. Hasta entonces, las trazas de este módulo se han tomado sin<StrictMode>para que se lean mejor; si quieres ver exactamente lo mismo, quítalo un momento desrc/main.tsx.
2. Anatomía de useEffect
Un efecto tiene tres partes:
useEffect(() => {
// 1. Montaje: conectar, suscribirse, arrancar algo
return () => {
// 2. Limpieza (opcional): desconectar, cancelar, parar
};
}, [/* 3. Dependencias: los valores que usa el efecto */]);
| Parte | Qué es | Cuándo se ejecuta |
|---|---|---|
| Función de montaje (setup) | El primer argumento | Tras el primer commit y tras cada commit en que cambie alguna dependencia |
| Limpieza (cleanup) | La función que devuelve la anterior, si la hay | Antes de cada nueva ejecución del efecto y al desmontar |
| Dependencias | El segundo argumento | React las compara con las del render anterior |
useEffect es un hook, así que sigue las reglas que viste en el módulo 04: se llama en el nivel superior del componente, nunca dentro de un if ni de un bucle. Si quieres que un efecto solo actúe en ciertos casos, pon el if dentro del efecto. Devuelve undefined: no se recoge ningún resultado.
Cuándo se ejecuta
Recuerda del módulo 05 los pasos de una actualización: disparador, render y commit. El efecto va detrás de todos ellos.
Figura 3. Por norma general, el efecto se ejecuta cuando el usuario ya está viendo el resultado.
Este componente escribe en consola qué hay en el DOM desde el render y desde el efecto:
import { useEffect, useState } from 'react';
function enPantalla() {
return document.getElementById('boton')?.textContent ?? '(todavía nada)';
}
export default function Traza() {
const [veces, setVeces] = useState(0);
console.log(`render: veces=${veces} | en pantalla: ${enPantalla()}`);
useEffect(() => {
console.log(`efecto: veces=${veces} | en pantalla: ${enPantalla()}`);
});
return (
<button id="boton" onClick={() => setVeces(veces + 1)}>
Pulsado {veces} veces
</button>
);
}
Esto es lo que aparece al cargar y al pulsar una vez:
render: veces=0 | en pantalla: (todavía nada)
efecto: veces=0 | en pantalla: Pulsado 0 veces
--- clic ---
render: veces=1 | en pantalla: Pulsado 0 veces
efecto: veces=1 | en pantalla: Pulsado 1 veces
La traza demuestra lo importante:
- El render de
veces=1todavía vePulsado 0 vecesen pantalla: durante el render, el DOM aún no ha cambiado. Por eso no puedes leer ni tocar el DOM ahí. - El efecto de ese mismo render ya ve
Pulsado 1 veces. Cuando se ejecuta un efecto, el DOM ya está al día. - El efecto conoce el valor de
vecesde su render. Recuerda las instantáneas del módulo 05: cada render tiene sus propias props, su estado y sus propios efectos.
💡 Consejo: El orden “después de pintar” tiene matices. Si el efecto lo ha causado una interacción, como un clic, React puede ejecutarlo antes de pintar. Por eso, si necesitas que algo bloqueante, como un
alert(), ocurra después de pintar, envuélvelo en unsetTimeout. En la sección 10 medirás cómo influye esto en un caso real.
Un efecto no puede ser async
Es lo primero que se intenta para pedir datos:
import { useEffect, useState } from 'react';
// ❌ No compila: un efecto no puede ser una función async
export default function AsyncDirecto() {
const [total, setTotal] = useState(0);
useEffect(async () => {
const res = await fetch('http://localhost:3000/api/estadisticas');
const datos = await res.json();
setTotal(datos.empleados);
}, []);
return <p>{total}</p>;
}
TypeScript lo rechaza:
src/App.tsx(7,13): error TS2345: Argument of type '() => Promise<void>' is not assignable to parameter of type 'EffectCallback'.
Type 'Promise<void>' is not assignable to type 'void | Destructor'.
Una función async siempre devuelve una promesa, y React espera que el efecto devuelva undefined o una función de limpieza. Una promesa no se puede ejecutar para limpiar nada. La solución es declarar la función asíncrona dentro y llamarla; lo verás en la sección 7.
El mismo error de tipos aparece por algo más sutil: una función flecha sin llaves devuelve lo que evalúa.
useEffect(() => setTimeout(() => console.log('hola'), 1000), []);
src/App.tsx(5,19): error TS2322: Type 'number' is not assignable to type 'void | Destructor'.
setTimeout devuelve un número en el navegador, y un número no es una limpieza. Escribe llaves: useEffect(() => { setTimeout(...); }, []). Y aun así, faltaría cancelarlo.
3. La lista de dependencias
El segundo argumento le dice a React cuándo debe volver a sincronizar. Hay tres formas de escribirlo y dan resultados muy distintos:
import { useEffect, useState } from 'react';
export default function TresFormas() {
const [a, setA] = useState(0);
const [b, setB] = useState(0);
useEffect(() => {
console.log('sin lista: tras cada render');
});
useEffect(() => {
console.log('lista vacía: solo al montar');
}, []);
useEffect(() => {
console.log(`[a]: a vale ${a}`);
}, [a]);
return (
<div className="fila">
<button onClick={() => setA(a + 1)}>a = {a}</button>
<button onClick={() => setB(b + 1)}>b = {b}</button>
</div>
);
}
sin lista: tras cada render
lista vacía: solo al montar
[a]: a vale 0
--- clic en a ---
sin lista: tras cada render
[a]: a vale 1
--- clic en b ---
sin lista: tras cada render
| Lista | Se ejecuta… | Uso real |
|---|---|---|
| Sin segundo argumento | Tras cada commit | Casi nunca; suele ser un descuido |
[] | Solo al montar (y limpia al desmontar) | El efecto no usa nada reactivo |
[a, b] | Al montar y cuando cambia a o b | El caso normal |
React compara cada dependencia con la del render anterior usando Object.is, que se comporta como === salvo en dos detalles: NaN es igual a NaN, y 0 es distinto de -0. Si alguna ha cambiado, repite el efecto.
⚠️ Importante: La lista debe tener siempre el mismo número de elementos y escribirse directamente, como
[a, b]. No la construyas con una variable ni la hagas variar entre renders.
Las dependencias no se eligen
Este es el cambio de mentalidad más importante del módulo. La lista no responde a “¿cuándo quiero que se ejecute?”, sino a “¿qué valores reactivos usa este código?”. Es una descripción del código, no una decisión.
Un valor reactivo es cualquiera que pueda cambiar entre renders: props, estado y todo lo que declares en el cuerpo del componente a partir de ellos. Si el efecto lo lee, debe estar en la lista.
Mira qué ocurre si mentimos. El siguiente componente muestra cuántos empleados tiene un departamento:
import { useEffect, useState } from 'react';
const API = 'http://localhost:3000/api';
// ❌ MAL: usa deptId dentro del efecto, pero no está en la lista
function Plantilla({ deptId }: { deptId: number }) {
const [total, setTotal] = useState<number | null>(null);
useEffect(() => {
fetch(`${API}/empleados?deptId=${deptId}`)
.then((res) => res.json())
.then((datos) => setTotal(datos.total));
}, []);
return <p className="tarjeta">Departamento {deptId}: {total ?? '...'} empleados</p>;
}
export default function App() {
const [deptId, setDeptId] = useState(20);
return (
<>
<div className="fila">
{[20, 40].map((d) => (
<button key={d} aria-pressed={d === deptId} onClick={() => setDeptId(d)}>{d}</button>
))}
</div>
<Plantilla deptId={deptId} />
</>
);
}
El departamento 20 tiene 6 empleados y el 40 tiene 3. Al pulsar el 40, esto es lo que se ve:
inicio: Departamento 20: 6 empleados
tras pulsar 40: Departamento 40: 6 empleados
La etiqueta dice 40 porque se calcula en el render, pero el número sigue siendo el del 20: el efecto no se ha repetido. Con [deptId] todo cuadra:
inicio: Departamento 20: 6 empleados
tras pulsar 40: Departamento 40: 3 empleados
¿Qué no es reactivo y, por tanto, no va en la lista?
| Valor | ¿Dependencia? | Por qué |
|---|---|---|
| Props y estado | Sí | Cambian entre renders |
| Variables y funciones del cuerpo del componente | Sí | Se crean de nuevo en cada render |
| Constantes y funciones fuera del componente | No | Son siempre las mismas |
La función set de useState | No | React garantiza que no cambia. Puedes ponerla sin problema |
Una ref de useRef | No | El objeto es siempre el mismo (módulo 10) |
location.pathname, ref.current | No debe | Son mutables: React no se entera de que cambian, así que no provocan render |
La clausura vieja
Un efecto es una clausura: captura las variables del render en que se creó. Si la lista miente, el efecto puede seguir viendo valores antiguos para siempre. Es el error más típico con temporizadores:
import { useEffect, useState } from 'react';
// ❌ MAL: el intervalo se queda para siempre con el primer valor de segundos
export default function ClausuraVieja() {
const [segundos, setSegundos] = useState(0);
useEffect(() => {
const id = setInterval(() => {
console.log(`tic: segundos=${segundos}`);
setSegundos(segundos + 1);
}, 1000);
return () => clearInterval(id);
}, []);
return <p className="tarjeta">Llevas {segundos} s en esta página</p>;
}
tic: segundos=0
tic: segundos=0
tic: segundos=0
pantalla: Llevas 1 s en esta página
El efecto se creó en el primer render, cuando segundos valía 0. El intervalo sigue vivo, pero la función que ejecuta es esa misma, y siempre calcula 0 + 1. La pantalla se queda en 1.
Añadir segundos a la lista funcionaría, pero destruiría y crearía el temporizador en cada tic. Lo mejor es que el efecto deje de leer el valor, con la función actualizadora del módulo 04:
import { useEffect, useState } from 'react';
export default function ClausuraBien() {
const [segundos, setSegundos] = useState(0);
useEffect(() => {
const id = setInterval(() => {
setSegundos((s) => s + 1); // recibe el valor actual: no necesita leer segundos
}, 1000);
return () => clearInterval(id);
}, []);
return <p className="tarjeta">Llevas {segundos} s en esta página</p>;
}
pantalla: Llevas 3 s en esta página
Ya no hay dependencias que declarar porque segundos no se lee. Para quitar una dependencia, demuestra con el código que no la necesitas; nunca la borres de la lista sin más.
Objetos y funciones como dependencias
Object.is compara objetos por identidad, no por contenido. Un objeto o una función declarados en el cuerpo del componente son nuevos en cada render, aunque tengan los mismos datos:
import { useEffect, useState } from 'react';
const API = 'http://localhost:3000/api';
type Empleado = { empId: number; nombre: string; apellidos: string };
// ❌ MAL: filtro es un objeto nuevo en cada render, así que "siempre ha cambiado"
function ListaDepartamento({ deptId }: { deptId: number }) {
const [empleados, setEmpleados] = useState<Empleado[]>([]);
const filtro = { deptId, tamano: 5 };
useEffect(() => {
fetch(`${API}/empleados?deptId=${filtro.deptId}&tamano=${filtro.tamano}`)
.then((res) => res.json())
.then((datos) => setEmpleados(datos.contenido));
}, [filtro]);
return (
<ul>
{empleados.map((e) => (
<li key={e.empId}>{e.nombre} {e.apellidos}</li>
))}
</ul>
);
}
export default function BucleObjeto() {
return <ListaDepartamento deptId={30} />;
}
El efecto carga datos, guardarlos provoca un render, el render crea otro filtro, y filtro distinto significa repetir el efecto. No aparece ningún error, pero contamos 121 peticiones en 2 segundos en una compilación de producción contra un servidor local. Contra un servidor real, eso es un ataque involuntario.
La solución es no depender del objeto:
import { useEffect, useState } from 'react';
const API = 'http://localhost:3000/api';
const TAMANO = 5;
type Empleado = { empId: number; nombre: string; apellidos: string };
function ListaDepartamento({ deptId }: { deptId: number }) {
const [empleados, setEmpleados] = useState<Empleado[]>([]);
useEffect(() => {
fetch(`${API}/empleados?deptId=${deptId}&tamano=${TAMANO}`)
.then((res) => res.json())
.then((datos) => setEmpleados(datos.contenido));
}, [deptId]);
return (
<ul>
{empleados.map((e) => (
<li key={e.empId}>{e.nombre} {e.apellidos}</li>
))}
</ul>
);
}
export default function BucleObjetoBien() {
return <ListaDepartamento deptId={30} />;
}
| Situación | Solución |
|---|---|
| El objeto o la función solo se usa en el efecto | Decláralo dentro del efecto |
| No depende de nada reactivo | Sácalo fuera del componente |
| Es un objeto que llega por props | Extrae sus campos primitivos y usa esos: [filtro.deptId] |
| Es una función que llega por props | Mira si es en realidad un evento (sección 9) o estabilízala con useCallback (módulo 14) |
El bucle síncrono
Cuando el efecto cambia el estado sin nada que lo detenga, el bucle es inmediato:
import { useEffect, useState } from 'react';
// ❌ MAL: el efecto cambia el estado y el cambio de estado vuelve a lanzar el efecto
export default function BucleSincrono() {
const [renders, setRenders] = useState(0);
useEffect(() => {
setRenders(renders + 1);
});
return <p className="tarjeta">Renders: {renders}</p>;
}
En desarrollo, React escribe en consola, una y otra vez:
Maximum update depth exceeded. This can happen when a component calls setState inside useEffect, but useEffect either doesn't have a dependency array, or one of the dependencies changes on every render.
Lo curioso es que no detiene nada: es un aviso, y el componente sigue renderizando. Tras 1,5 segundos, la pantalla marcaba Renders: 2678 en desarrollo y Renders: 7629 en producción, donde el aviso ni siquiera existe. Un bucle así no rompe la página de forma visible, pero deja un núcleo de la CPU al 100 %.
4. La limpieza
Volvamos al reloj. Le añadimos un botón para ocultarlo y contamos cuántos temporizadores siguen vivos tras cerrar y abrir tres veces:
al montar: temporizadores vivos = 1
tras cerrar y abrir (1): temporizadores vivos = 2
tras cerrar y abrir (2): temporizadores vivos = 3
tras cerrar y abrir (3): temporizadores vivos = 4
Cada vez que el reloj aparece, arranca un temporizador; cuando desaparece, nadie lo para. Ese temporizador sigue llamando a setAhora sobre un componente que ya no existe. Es una fuga: un recurso que se pide y no se devuelve.
La función que devuelve el efecto es la limpieza. React la llama cuando el efecto deja de valer:
import { useEffect, useState } from 'react';
function Reloj() {
const [ahora, setAhora] = useState(() => new Date());
useEffect(() => {
const id = setInterval(() => setAhora(new Date()), 1000);
return () => clearInterval(id);
}, []);
return <p className="tarjeta">Son las {ahora.toLocaleTimeString('es-ES')}</p>;
}
export default function App() {
const [visible, setVisible] = useState(true);
return (
<>
<button onClick={() => setVisible(!visible)}>{visible ? 'Ocultar' : 'Mostrar'} reloj</button>
{visible && <Reloj />}
</>
);
}
al montar: temporizadores vivos = 1
tras cerrar y abrir (1): temporizadores vivos = 1
tras cerrar y abrir (2): temporizadores vivos = 1
tras cerrar y abrir (3): temporizadores vivos = 1
oculto: temporizadores vivos = 0
Ahora siempre hay uno, y cero al ocultarlo.
📘 Concepto: La limpieza debe deshacer lo que hizo el efecto, de forma que el usuario no note la diferencia entre “se montó una vez” y “se montó, se limpió y se volvió a montar”. Si el efecto conecta, la limpieza desconecta. Si se suscribe, se da de baja. Si arranca, para.
| El efecto… | La limpieza… |
|---|---|
setInterval / setTimeout | clearInterval / clearTimeout |
addEventListener | removeEventListener, con la misma función |
Abre una conexión o un WebSocket | close() o disconnect() |
Crea un IntersectionObserver | disconnect() |
Lanza un fetch | AbortController.abort() o una bandera (sección 7) |
| Inicia una animación | cancel() o volver al estado inicial |
| Crea un objeto de una librería externa | Su destroy() o equivalente |
⚠️ Importante:
removeEventListenersolo funciona con la misma referencia de función.removeEventListener('resize', () => actualizar())no quita nada, porque esa flecha es una función nueva. Guarda la función en una constante dentro del efecto, comoactualizaren el ejercicio 1.
La limpieza ve los valores antiguos
Para el resto del módulo usaremos un sistema externo simulado: una conexión a una sala de avisos, parecida a un WebSocket. Guárdala en src/conexion.ts:
// Simula un canal de avisos en tiempo real, como un WebSocket.
// Tarda 100 ms en conectar y avisa con la función que le pases a alConectar.
export function crearConexion(sala: string) {
let avisar = () => {};
let temporizador: number | undefined;
return {
conectar() {
console.log(`conectar a ${sala}`);
temporizador = setTimeout(() => avisar(), 100);
},
desconectar() {
console.log(`desconectar de ${sala}`);
clearTimeout(temporizador);
},
alConectar(funcion: () => void) {
avisar = funcion;
},
};
}
Este componente se conecta a la sala que recibe por props:
import { useEffect, useState } from 'react';
import { crearConexion } from './conexion';
function Sala({ nombre }: { nombre: string }) {
useEffect(() => {
const conexion = crearConexion(nombre);
conexion.conectar();
return () => conexion.desconectar();
}, [nombre]);
return <p className="tarjeta">Avisos de la sala {nombre}</p>;
}
export default function App() {
const [sala, setSala] = useState('VENTAS');
const [visible, setVisible] = useState(true);
return (
<>
<div className="fila">
<select value={sala} onChange={(e) => setSala(e.target.value)}>
<option>VENTAS</option>
<option>MARKETING</option>
<option>INFORMÁTICA</option>
</select>
<button onClick={() => setVisible(!visible)}>{visible ? 'Cerrar' : 'Abrir'} sala</button>
</div>
{visible && <Sala nombre={sala} />}
</>
);
}
Cambiamos de sala y después la cerramos, en una versión sin <StrictMode>:
conectar a VENTAS
--- elegir MARKETING ---
desconectar de VENTAS
conectar a MARKETING
--- cerrar la sala ---
desconectar de MARKETING
Fíjate en el orden al cambiar a MARKETING: primero se desconecta de VENTAS y después se conecta a la nueva. La limpieza es una clausura del render anterior, así que lee el nombre antiguo y, sobre todo, la conexion antigua. React ejecuta la limpieza vieja con los valores viejos y luego el efecto nuevo con los nuevos. No hay que guardar la conexión anterior en ningún sitio.
5. El ciclo de vida de un efecto
Es tentador pensar en los efectos con las fases del componente: “esto se ejecuta al montar, esto al actualizar, esto al desmontar”. Ese modelo viene de los componentes de clase y lleva a errores. Un componente se monta, se actualiza y se desmonta. Un efecto solo hace dos cosas: empezar a sincronizar y dejar de hacerlo, y puede repetirlas muchas veces mientras el componente sigue en pantalla.
Figura 4. Un componente, dos sincronizaciones. Cada una tiene su propio inicio y su propio final.
Esta forma de pensar simplifica el código. Cuando escribas un efecto, olvida el componente y responde solo a dos preguntas: ¿cómo empieza esta sincronización?, ¿cómo termina? Si el código de Sala responde bien a ambas para un nombre cualquiera, funcionará al montar, al cambiar de sala y al desmontar, sin un if para cada caso.
Un efecto para cada proceso
Como un efecto es un proceso de sincronización, no lo uses de cajón de sastre. Mira esta versión:
useEffect(() => {
registrarVisita(nombre);
const conexion = crearConexion(nombre);
conexion.conectar();
return () => conexion.desconectar();
}, [nombre, tema]);
Si añades tema a la lista porque lo necesita la conexión, cada cambio de tema contaría una visita falsa. Conviene separarlos:
function Sala({ nombre }: { nombre: string }) {
// Proceso 1: mantener la conexión con la sala actual
useEffect(() => {
const conexion = crearConexion(nombre);
conexion.conectar();
return () => conexion.desconectar();
}, [nombre]);
// Proceso 2: registrar la visita. Nada que ver con la conexión
useEffect(() => {
console.log(`visita registrada: ${nombre}`);
}, [nombre]);
return <p className="tarjeta">Avisos de la sala {nombre}</p>;
}
conectar a VENTAS
visita registrada: VENTAS
--- cambiar de sala ---
desconectar de VENTAS
conectar a MARKETING
visita registrada: MARKETING
El criterio no es “van en momentos parecidos”, sino “¿son el mismo proceso?”. Si borrar uno de los efectos no afecta al otro, deben ir separados.
6. Strict Mode: el doble arranque en desarrollo
Como viste en el módulo 05, <StrictMode> activa comprobaciones que solo funcionan en desarrollo. Una de ellas afecta directamente a los efectos: tras montar un componente, React ejecuta la limpieza y vuelve a ejecutar el efecto, una sola vez, como prueba.
Esta es la misma sala de antes, ahora con <StrictMode> en desarrollo:
conectar a VENTAS
desconectar de VENTAS
conectar a VENTAS
--- elegir MARKETING ---
desconectar de VENTAS
conectar a MARKETING
--- cerrar la sala ---
desconectar de MARKETING
Y esta es la compilación de producción, también con <StrictMode>:
conectar a VENTAS
--- elegir MARKETING ---
desconectar de VENTAS
conectar a MARKETING
--- cerrar la sala ---
desconectar de MARKETING
Las tres primeras líneas de la primera traza son la prueba de estrés. En producción no existe. React hace esto porque un usuario puede salir de una pantalla y volver, y porque hay funciones del propio React que apagan y encienden efectos conservando el estado. Si tu efecto no lo aguanta, falla ahora, en tu ordenador, y no en producción.
📘 Concepto: La doble ejecución no es un fallo que haya que evitar: es un detector de limpiezas que faltan. Si el resultado final con montaje, limpieza, montaje es igual al que tendrías con un solo montaje, tu efecto es correcto. Por ejemplo, el reloj sin limpieza de la sección 1 arranca dos temporizadores en desarrollo desde el primer momento: la prueba deja el fallo a la vista.
El parche que no debes usar
La reacción habitual es impedir que el efecto se repita. Este ejemplo usa useRef, una forma de guardar un valor que sobrevive entre renders y que estudiarás en el módulo 10:
// ❌ MAL: "parche" para que el efecto solo se ejecute una vez
function Sala({ nombre }: { nombre: string }) {
const yaConectado = useRef(false);
useEffect(() => {
if (yaConectado.current) return;
yaConectado.current = true;
const conexion = crearConexion(nombre);
conexion.conectar();
return () => conexion.desconectar();
}, [nombre]);
return <p className="tarjeta">Avisos de la sala {nombre}</p>;
}
La traza muestra por qué es peor que el problema:
conectar a VENTAS
desconectar de VENTAS
--- elegir MARKETING ---
fin de la traza
La prueba de Strict Mode ejecuta la limpieza y desconecta. Al volver a montar, la ref dice que “ya está conectado”, así que no hace nada: la sala se queda sin conexión. Y al elegir MARKETING ni siquiera lo intenta. El parche ha convertido un aviso útil en un fallo real.
| Si tu efecto… | Haz esto |
|---|---|
| Conecta, se suscribe o arranca algo | Escribe la limpieza que lo deshace |
| Pide datos | Cancela o ignora la respuesta en la limpieza (sección 7) |
| Registra analítica | Déjalo: en desarrollo contará doble, pero en producción no |
| Compra, envía o cobra | No debe ser un efecto: va en un manejador de eventos (sección 8) |
| Inicializa la aplicación una sola vez | Sácalo del componente (sección 8) |
Hidratación y Fast Refresh 🔄 19.3
🔄 Cambia en React 19.3. En React 19.0, 19.1 y 19.2, Strict Mode no repite los efectos al hidratar con
hydrateRootni al remontar un componente con Fast Refresh. Si usas una versión anterior: esos dos casos no te avisarán de una limpieza que falta, así que comprueba el componente también en un árbol creado concreateRoot, donde la repetición sí ocurre.
La hidratación, que estudiarás en el módulo 23, conecta React a un HTML que ya vino del servidor. Fast Refresh remonta un componente cuando editas sus hooks. Medimos ambos casos, con los mismos dos programas, en cada versión:
| Versión | Doble ejecución al hidratar | Doble ejecución al remontar con Fast Refresh |
|---|---|---|
| 18.3.1 | Sí | Sí |
| 19.0.9, 19.1.10 y 19.2.9 | No | No |
| 19.3.1 | Sí | Sí |
Al crear el árbol con createRoot la repetición ocurre en todas ellas. Lo que React 19.3 recupera es la coherencia: la prueba se hace en todas las formas de montar.
Efectos que paran y vuelven: <Activity> 🆕 19.2
🆕 Desde React 19.2. No existe en React 19.1 ni anteriores. Si usas una versión anterior: no hay equivalente exacto. Para ocultar una parte de la interfaz sin perder su estado, elévalo a un componente padre o usa
display: none; en ese caso los efectos siguen activos.
<Activity> es la mejor demostración de por qué los efectos deben poder repetirse. Oculta sus hijos conservando el estado, pero apaga sus efectos mientras están ocultos. Lo verás a fondo en el módulo 17. Basta con un ejemplo:
import { Activity, useEffect, useState } from 'react';
import { crearConexion } from './conexion';
function Sala({ nombre }: { nombre: string }) {
const [notas, setNotas] = useState('');
useEffect(() => {
const conexion = crearConexion(nombre);
conexion.conectar();
return () => conexion.desconectar();
}, [nombre]);
return <input value={notas} placeholder="Notas" onChange={(e) => setNotas(e.target.value)} />;
}
export default function EnActivity() {
const [abierta, setAbierta] = useState(true);
return (
<section className="tarjeta">
<button onClick={() => setAbierta(!abierta)}>{abierta ? 'Ocultar' : 'Mostrar'}</button>{' '}
<Activity mode={abierta ? 'visible' : 'hidden'}>
<Sala nombre="VENTAS" />
</Activity>
</section>
);
}
conectar a VENTAS
--- ocultar ---
desconectar de VENTAS
--- mostrar ---
conectar a VENTAS
notas conservadas: llamar a Luis
El componente no se ha desmontado, porque el texto escrito sigue ahí, y sin embargo su efecto se ha limpiado y ha vuelto a arrancar. Si tu limpieza es correcta, <Activity> funciona gratis.
7. Pedir datos con fetch
Cargar los datos de una pantalla es el uso más conocido de useEffect, y también el que esconde más trampas. Vamos a escribirlo bien paso a paso. Si necesitas repasar las promesas y async/await, vuelve al Curso de JavaScript.
Primera versión: carga, error y datos
Como un efecto no puede ser async, declaramos dentro una función asíncrona y la llamamos:
import { useEffect, useState } from 'react';
const API = 'http://localhost:3000/api';
type Empleado = {
empId: number;
nombre: string;
apellidos: string;
oficio: string;
departamento: string;
};
function FichaEmpleado({ id }: { id: number }) {
const [empleado, setEmpleado] = useState<Empleado | null>(null);
const [cargando, setCargando] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
async function cargar() {
setCargando(true);
setError(null);
try {
const res = await fetch(`${API}/empleados/${id}`);
if (!res.ok) {
const cuerpo = await res.json();
throw new Error(cuerpo.mensaje);
}
setEmpleado(await res.json());
} catch (e) {
setError((e as Error).message);
} finally {
setCargando(false);
}
}
cargar();
}, [id]);
if (cargando) return <p className="tenue">Cargando empleado {id}...</p>;
if (error) return <p className="error">{error}</p>;
return (
<div>
<h2>{empleado?.nombre} {empleado?.apellidos}</h2>
<p>{empleado?.oficio} en {empleado?.departamento}</p>
</div>
);
}
export default function App() {
const [id, setId] = useState(100);
return (
<section className="tarjeta">
<div className="fila">
{[100, 101, 102, 9999].map((n) => (
<button key={n} aria-pressed={n === id} onClick={() => setId(n)}>{n}</button>
))}
</div>
<FichaEmpleado id={id} />
</section>
);
}

Figura 5. La ficha con datos reales de la API.
Fíjate en if (!res.ok). fetch solo rechaza la promesa si no llega a haber respuesta, por ejemplo sin red. Un 404 o un 500 son respuestas válidas para fetch. Lo comprobamos pidiendo un empleado que no existe y sin mirar ok: la promesa se resuelve sin error, con ok=false status=404. Si no lo compruebas, guardarás como empleado el cuerpo del error.

Figura 6. El mensaje de error llega de la propia API, en el campo mensaje.
Esta versión parece terminada, pero tiene un fallo grave que no se ve en local.
Condiciones de carrera
Cuando id cambia, se lanza otra petición sin que nadie se ocupe de la anterior. Las dos están en marcha a la vez, y nada garantiza que respondan en orden. En un servidor local responden casi al instante, así que vamos a simular la red real: el empleado 100 tardará 1,5 segundos y los demás, 0,2.
import { useEffect, useState } from 'react';
const API = 'http://localhost:3000/api';
type Empleado = { empId: number; nombre: string; apellidos: string; oficio: string };
function esperar(ms: number) {
return new Promise((resolver) => setTimeout(resolver, ms));
}
// Red simulada: el empleado 100 tarda 1,5 s y el resto, 0,2 s
async function pedirEmpleado(id: number): Promise<Empleado> {
await esperar(id === 100 ? 1500 : 200);
const res = await fetch(`${API}/empleados/${id}`);
return res.json();
}
// ❌ MAL: la última respuesta en llegar gana, sea o no la última pedida
function FichaEmpleado({ id }: { id: number }) {
const [empleado, setEmpleado] = useState<Empleado | null>(null);
useEffect(() => {
pedirEmpleado(id).then((datos) => setEmpleado(datos));
}, [id]);
if (!empleado) return <p className="tenue">Cargando...</p>;
return <p><b>{empleado.nombre} {empleado.apellidos}</b> (n.º {empleado.empId}), {empleado.oficio}</p>;
}
export default function App() {
const [id, setId] = useState(101);
return (
<section className="tarjeta">
<h2>Ficha del empleado</h2>
<div className="fila">
{[100, 101].map((n) => (
<button key={n} aria-pressed={n === id} onClick={() => setId(n)}>Empleado {n}</button>
))}
</div>
<FichaEmpleado id={id} />
</section>
);
}
Pulsamos Empleado 100 e inmediatamente Empleado 101:
inicio: Luis Martínez Ruiz (n.º 101), DIRECTOR
a los 0,7 s: Luis Martínez Ruiz (n.º 101), DIRECTOR
a los 2 s: Ana García López (n.º 100), DIRECTOR GENERAL

Figura 7. Captura real del fallo: está seleccionado el 101 y se muestra el 100.
La aplicación enseña datos de un empleado distinto del elegido, sin ningún error. En una pantalla de nóminas o de pedidos, esto es grave.
Figura 8. Una condición de carrera: el resultado depende de quién llegue antes.
La solución ya la conoces: cada efecto debe limpiar lo que empezó. Cuando id cambia, React ejecuta la limpieza del efecto anterior antes de lanzar el siguiente. Solo falta aprovecharlo.
Solución 1: la bandera ignorar
Cada ejecución del efecto tiene su propia variable local:
useEffect(() => {
let ignorar = false;
pedirEmpleado(id).then((datos) => {
if (!ignorar) setEmpleado(datos);
});
return () => {
ignorar = true;
};
}, [id]);
inicio: Luis Martínez Ruiz (n.º 101), DIRECTOR
a los 0,7 s: Luis Martínez Ruiz (n.º 101), DIRECTOR
a los 2 s: Luis Martínez Ruiz (n.º 101), DIRECTOR
Cuando la respuesta antigua por fin llega, su ignorar vale true, porque la limpieza de ese efecto la cambió. Cada clausura tiene su propio ignorar, así que la respuesta vigente no se ve afectada.
Solución 2: cancelar con AbortController
La bandera descarta la respuesta, pero la petición sigue su curso y consume red. Si además quieres cancelarla de verdad, usa AbortController, un objeto estándar del navegador. Le pasas su signal a fetch, y al llamar a abort() la promesa se rechaza.
async function pedirEmpleado(id: number, signal: AbortSignal): Promise<Empleado> {
await esperar(id === 100 ? 1500 : 200);
const res = await fetch(`${API}/empleados/${id}`, { signal });
return res.json();
}
useEffect(() => {
const control = new AbortController();
pedirEmpleado(id, control.signal)
.then((datos) => setEmpleado(datos))
.catch((e: Error) => {
if (!control.signal.aborted) console.error(e);
});
return () => control.abort();
}, [id]);
inicio: Luis Martínez Ruiz (n.º 101), DIRECTOR
a los 0,7 s: Luis Martínez Ruiz (n.º 101), DIRECTOR
a los 2 s: Luis Martínez Ruiz (n.º 101), DIRECTOR

Figura 9. La misma secuencia de clics tras añadir la limpieza.
¿Se cancela la petición de verdad? Para comprobarlo arrancamos la API con un retardo de 1,5 s en todas sus respuestas:
RETARDO=1500 node server.mjs # Git Bash, Linux o macOS
$env:RETARDO=1500; node server.mjs # PowerShell
Con la versión final de la ficha, que verás enseguida, entramos en la página y pulsamos 101 y 102 seguidos:
pantalla: 100 | 101 | 102 | 9999 | Marta Sánchez Gil | | DIRECTOR en INFORMÁTICA
peticiones: /empleados/100, /empleados/100, /empleados/101, /empleados/102
canceladas: /empleados/100: net::ERR_ABORTED, /empleados/100: net::ERR_ABORTED, /empleados/101: net::ERR_ABORTED
Hay dos peticiones al empleado 100 porque Strict Mode ejecuta el efecto dos veces, y las dos se cancelan, igual que la del 101. En la pestaña Network de las herramientas del navegador aparecen como (canceled). El doble arranque sirve aquí para comprobar que la cancelación funciona.
Bandera ignorar | AbortController | |
|---|---|---|
| Evita mostrar datos antiguos | Sí | Sí |
| Libera la red | No: la petición termina | Sí |
| Funciona con cualquier promesa | Sí | Solo con APIs que aceptan signal |
| Hay que tratar un error nuevo | No | Sí: el rechazo por cancelación |
Cómo se distingue una cancelación
Probamos en Chromium qué recibe el catch en cada caso:
| Llamada | e.name | Tipo de e |
|---|---|---|
control.abort() | AbortError | DOMException |
control.abort('motivo') | undefined | String: rechaza con lo que le pases |
AbortSignal.timeout(100) | TimeoutError | DOMException |
fetch con una señal ya abortada | AbortError | DOMException, sin llegar a enviar nada |
La tabla explica por qué en este módulo se comprueba control.signal.aborted y no e.name === 'AbortError': la señal te dice qué ha pasado sea cual sea el motivo de la cancelación.
⚠️ Importante: Una cancelación provocada por la limpieza no es un error. Si la tratas como tal, cada cambio de
idhará parpadear un mensaje rojo, y en Strict Mode aparecerá nada más cargar.
Tiempo máximo
AbortSignal.timeout(ms) crea una señal que se aborta sola, y AbortSignal.any([...]) combina varias. Así cancelamos por lo que ocurra primero: que el componente se vaya o que se agote el tiempo.
import { useEffect, useState } from 'react';
const API = 'http://localhost:3000/api';
type Estadisticas = { empleados: number; pedidos: number };
export default function FichaLimite() {
const [datos, setDatos] = useState<Estadisticas | null>(null);
const [aviso, setAviso] = useState('');
useEffect(() => {
const control = new AbortController();
// Se cancela lo que ocurra antes: desmontar el componente o agotar el tiempo
const signal = AbortSignal.any([control.signal, AbortSignal.timeout(800)]);
fetch(`${API}/estadisticas`, { signal })
.then((res) => res.json())
.then(setDatos)
.catch((e: Error) => {
if (control.signal.aborted) return;
setAviso(e.name === 'TimeoutError' ? 'La API tarda demasiado' : e.message);
});
return () => control.abort();
}, []);
if (aviso) return <p className="tarjeta error">{aviso}</p>;
if (!datos) return <p className="tarjeta tenue">Cargando...</p>;
return <p className="tarjeta">{datos.empleados} empleados y {datos.pedidos} pedidos</p>;
}
Con la API en RETARDO=1500 y un máximo de 800 ms:
pantalla: La API tarda demasiado
Aquí sí usamos e.name, para distinguir el límite de tiempo de cualquier otro fallo.
Una forma mejor de modelar el estado
La primera versión tenía tres estados (empleado, cargando, error) que había que mantener coherentes a mano. Además, si llamas a setCargando(true) directamente en el cuerpo del efecto, el linter lo señala, y con razón: provoca un render extra justo después de cada cambio (sección 12).
Hay una alternativa que elimina el problema: guardar a qué id pertenece el resultado y deducir la carga durante el render.
import { useEffect, useState } from 'react';
const API = 'http://localhost:3000/api';
type Empleado = {
empId: number;
nombre: string;
apellidos: string;
oficio: string;
departamento: string;
};
type Resultado =
| { id: number; tipo: 'ok'; empleado: Empleado }
| { id: number; tipo: 'error'; mensaje: string };
async function pedirEmpleado(id: number, signal: AbortSignal): Promise<Empleado> {
const res = await fetch(`${API}/empleados/${id}`, { signal });
const cuerpo = await res.json();
if (!res.ok) throw new Error(cuerpo.mensaje);
return cuerpo;
}
function FichaEmpleado({ id }: { id: number }) {
const [resultado, setResultado] = useState<Resultado | null>(null);
useEffect(() => {
const control = new AbortController();
pedirEmpleado(id, control.signal)
.then((empleado) => setResultado({ id, tipo: 'ok', empleado }))
.catch((e: Error) => {
if (control.signal.aborted) return;
setResultado({ id, tipo: 'error', mensaje: e.message });
});
return () => control.abort();
}, [id]);
// Si el resultado guardado es de otro id, es que el nuestro aún no ha llegado
if (resultado?.id !== id) return <p className="tenue">Cargando empleado {id}...</p>;
if (resultado.tipo === 'error') return <p className="error">{resultado.mensaje}</p>;
const { empleado } = resultado;
return (
<div>
<h2>{empleado.nombre} {empleado.apellidos}</h2>
<p>{empleado.oficio} en {empleado.departamento}</p>
</div>
);
}
export default function App() {
const [id, setId] = useState(100);
return (
<section className="tarjeta">
<div className="fila">
{[100, 101, 102, 9999].map((n) => (
<button key={n} aria-pressed={n === id} onClick={() => setId(n)}>{n}</button>
))}
</div>
<FichaEmpleado id={id} />
</section>
);
}
Las ventajas son notables:
cargandono se guarda: esresultado?.id !== id. No puede quedarse desincronizado.- Al cambiar de
id, en el mismo render ya se muestra “Cargando…”, sin un fotograma con los datos del empleado anterior. - Ningún
setStatese ejecuta en el cuerpo del efecto, solo cuando llega la respuesta. - La unión de tipos impide leer
empleadosin haber comprobado antes que hay uno.
💡 Consejo: Este patrón, junto con
AbortController, es el que usaremos en los ejercicios. El módulo 12 lo encapsulará en un hook propio,useFetch.
Lo que un efecto no te da
Aunque lo escribas perfecto, un fetch en un efecto tiene límites que no se resuelven con más cuidado:
- Sin caché. Si sales de la pantalla y vuelves, se pide todo otra vez y se ve de nuevo “Cargando…”.
- Sin datos en el HTML inicial. Los efectos no se ejecutan en el servidor, así que con renderizado en servidor el usuario espera a que cargue JavaScript.
- Cascadas. Si un padre y su hijo piden datos en efectos, el hijo no empieza hasta que el padre ha terminado y lo ha mostrado.
- Duplicados. Dos componentes que necesitan lo mismo hacen dos peticiones.
Para aprender y para aplicaciones pequeñas es perfectamente válido. En un proyecto real conviene una herramienta pensada para ello: TanStack Query, los loader de React Router, o use(promesa) con Suspense. Entender lo que has visto aquí te permitirá saber qué resuelven por ti.
8. Quizá no necesitas un efecto
Cuando se descubre useEffect, todo parece un buen sitio para usarlo. Pero un efecto innecesario hace el código más lento, más largo y más propenso a errores. Si no hay un sistema externo de por medio, casi seguro que sobra.
Figura 10. Antes de escribir un efecto, recorre estas preguntas.
Datos que se pueden calcular
Es el error más común: guardar en el estado algo que se deduce de otro estado.
import { useEffect, useState } from 'react';
type Linea = { producto: string; precio: number; cantidad: number };
const inicial: Linea[] = [
{ producto: 'Portátil', precio: 899, cantidad: 1 },
{ producto: 'Ratón', precio: 25, cantidad: 2 },
];
// ❌ MAL: guarda en estado algo que se puede calcular
export default function CarritoEfecto() {
const [lineas, setLineas] = useState(inicial);
const [total, setTotal] = useState(0);
useEffect(() => {
setTotal(lineas.reduce((suma, l) => suma + l.precio * l.cantidad, 0));
}, [lineas]);
console.log(`render: ${lineas.length} líneas, total=${total}`);
return (
<section className="tarjeta">
<p>{lineas.length} líneas, total {total} €</p>
<button onClick={() => setLineas([...lineas, { producto: 'Teclado', precio: 50, cantidad: 1 }])}>
Añadir teclado
</button>
</section>
);
}
render: 2 líneas, total=0
render: 2 líneas, total=949
--- añadir ---
render: 3 líneas, total=949
render: 3 líneas, total=999
Cada cambio cuesta dos renders, y el primero se pinta con un total incorrecto: 3 líneas y 949 €. La versión correcta tiene menos código:
export default function CarritoDerivado() {
const [lineas, setLineas] = useState(inicial);
const total = lineas.reduce((suma, l) => suma + l.precio * l.cantidad, 0);
console.log(`render: ${lineas.length} líneas, total=${total}`);
return (
<section className="tarjeta">
<p>{lineas.length} líneas, total {total} €</p>
<button onClick={() => setLineas([...lineas, { producto: 'Teclado', precio: 50, cantidad: 1 }])}>
Añadir teclado
</button>
</section>
);
}
render: 2 líneas, total=949
--- añadir ---
render: 3 líneas, total=999
Un render, sin estado intermedio y sin nada que sincronizar. Si el cálculo fuese realmente costoso, lo envolverías en useMemo (módulo 14), que sigue siendo un cálculo durante el render.
Cadenas de efectos
Cuando un efecto cambia un estado que dispara otro efecto, el problema se multiplica:
// ❌ MAL: cada efecto reacciona al anterior y un solo cambio provoca varios renders
export function CadenaMal({ lineas }: { lineas: Linea[] }) {
const [subtotal, setSubtotal] = useState(0);
const [iva, setIva] = useState(0);
const [total, setTotal] = useState(0);
useEffect(() => {
setSubtotal(calcularSubtotal(lineas));
}, [lineas]);
useEffect(() => {
setIva(subtotal * 0.21);
}, [subtotal]);
useEffect(() => {
setTotal(subtotal + iva);
}, [subtotal, iva]);
console.log(`render: subtotal=${subtotal} iva=${iva} total=${total}`);
return <p>{total}</p>;
}
render: subtotal=0 iva=0 total=0
render: subtotal=200 iva=0 total=0
render: subtotal=200 iva=42 total=200
render: subtotal=200 iva=42 total=242
Son cuatro renders para mostrar una sola cifra, con tres resultados intermedios falsos. Todo se reduce a tres líneas durante el render:
export function CadenaBien({ lineas }: { lineas: Linea[] }) {
const subtotal = calcularSubtotal(lineas);
const iva = subtotal * 0.21;
const total = subtotal + iva;
console.log(`render: subtotal=${subtotal} iva=${iva} total=${total}`);
return <p>{total}</p>;
}
Reiniciar el estado al cambiar una prop
Quieres vaciar una nota cuando cambias de empleado. El efecto parece natural:
// ❌ MAL: un fotograma con la nota del empleado anterior y un render de más
function Nota({ empleadoId }: { empleadoId: number }) {
const [texto, setTexto] = useState('');
useEffect(() => {
setTexto('');
}, [empleadoId]);
console.log(`render: empleado ${empleadoId}, texto="${texto}"`);
return <textarea value={texto} onChange={(e) => setTexto(e.target.value)} />;
}
render: empleado 100, texto=""
render: empleado 100, texto="Hola"
render: empleado 100, texto="Hola"
--- cambiar a Luis ---
render: empleado 101, texto="Hola"
render: empleado 101, texto=""
Hay un render con el empleado 101 y la nota de Ana todavía escrita. Con la key del módulo 05, React trata cada empleado como un componente distinto y empieza de cero:
<Nota key={id} empleadoId={id} />
render: empleado 100, texto=""
render: empleado 100, texto="Hola"
--- cambiar a Luis ---
render: empleado 101, texto=""
Conservar la selección
Si guardas el objeto seleccionado, tendrás que mantenerlo al día con otro efecto. Guarda solo su identificador y deduce el objeto:
// Guarda solo el id; el objeto se deriva en cada render
export function ListaBien({ empleados }: { empleados: Empleado[] }) {
const [seleccionId, setSeleccionId] = useState<number | null>(null);
const seleccion = empleados.find((e) => e.empId === seleccionId) ?? null;
return <p onClick={() => setSeleccionId(empleados[0].empId)}>{seleccion?.nombre}</p>;
}
Si el empleado desaparece de la lista, seleccion será null sin hacer nada más.
Lógica que pertenece a un evento
El mensaje “Pedido enviado” se debe a que el usuario pulsó un botón, no a que el componente apareció en pantalla. Mira qué pasa al ponerlo en un efecto, con el estado guardado en localStorage:
// ❌ MAL: el aviso depende de un evento (pulsar), no de que el componente exista
export default function PedidoEfecto() {
const [enviado, setEnviado] = useState(() => localStorage.getItem('enviado') === 'si');
useEffect(() => {
if (enviado) avisar('Pedido enviado');
}, [enviado]);
function enviar() {
localStorage.setItem('enviado', 'si');
setEnviado(true);
}
return <button onClick={enviar} disabled={enviado}>{enviado ? 'Enviado' : 'Enviar pedido'}</button>;
}
--- enviar ---
aviso: Pedido enviado
--- recargar la página ---
aviso: Pedido enviado
Tras recargar la página, el usuario ve un aviso de un envío de ayer. En el manejador el problema desaparece:
function enviar() {
localStorage.setItem('enviado', 'si');
setEnviado(true);
avisar('Pedido enviado'); // aquí sabemos exactamente qué ha ocurrido
}
--- enviar ---
aviso: Pedido enviado
--- recargar la página ---
Lo mismo vale para cualquier petición POST: enviar un formulario es un evento. Si lo hicieras en un efecto, Strict Mode lo enviaría dos veces, y con razón te estaría avisando.
Avisar al padre
Un efecto que llama a una prop onCambio hace que el padre se entere un render más tarde. Llámala en el mismo manejador; las dos actualizaciones se agrupan en un solo render:
export function InterruptorBien({ onCambio }: { onCambio: (activo: boolean) => void }) {
const [activo, setActivo] = useState(false);
function pulsar() {
setActivo(!activo);
onCambio(!activo); // ambos cambios se agrupan en un solo render
}
return <button onClick={pulsar}>{activo ? 'Sí' : 'No'}</button>;
}
Inicializar la aplicación
Lo que debe ejecutarse una vez por carga de la aplicación, como leer un token o preparar el registro de errores, no pertenece a un componente. Ponlo en main.tsx, antes de createRoot. Así no se repite al remontar, y no necesitas ningún parche como el de la sección 6.
Suscribirse a un almacén externo
Saber si hay conexión a Internet es un ejemplo legítimo de efecto:
import { useEffect, useState } from 'react';
// Funciona, pero enseguida verás una forma mejor: useSyncExternalStore
export default function EstadoRed() {
const [enLinea, setEnLinea] = useState(() => navigator.onLine);
useEffect(() => {
const actualizar = () => setEnLinea(navigator.onLine);
window.addEventListener('online', actualizar);
window.addEventListener('offline', actualizar);
return () => {
window.removeEventListener('online', actualizar);
window.removeEventListener('offline', actualizar);
};
}, []);
return <p className="tarjeta">{enLinea ? 'Con conexión' : 'Sin conexión'}</p>;
}
Pero React tiene un hook específico para leer valores que cambian fuera de él, que evita lecturas incoherentes durante el renderizado concurrente:
import { useSyncExternalStore } from 'react';
function suscribir(avisar: () => void) {
window.addEventListener('online', avisar);
window.addEventListener('offline', avisar);
return () => {
window.removeEventListener('online', avisar);
window.removeEventListener('offline', avisar);
};
}
export default function EstadoRed() {
const enLinea = useSyncExternalStore(suscribir, () => navigator.onLine);
return <p className="tarjeta">{enLinea ? 'Con conexión' : 'Sin conexión'}</p>;
}
Comprobado desconectando la red del navegador, el texto cambia a “Sin conexión” y vuelve a “Con conexión”. Profundizarás en él en el módulo 12.
El título de la pestaña 🆕 19.0
🆕 Desde React 19.0. No existe en React 18.3 ni anteriores: allí un
<title>se queda en el<body>y no cambia la pestaña. Si usas una versión anterior: usa un efecto condocument.title, como el que se muestra aquí debajo.
Cambiar document.title era el primer efecto que se enseñaba, y sigue funcionando:
useEffect(() => {
document.title = `(${pendientes}) Pedidos pendientes`;
}, [pendientes]);
Desde React 19, el <title> puede escribirse en cualquier componente y React lo lleva al <head>, como viste en el módulo 07:
<section className="tarjeta">
<title>{`(${pendientes}) Pedidos pendientes`}</title>
<p>Tienes {pendientes} pedidos pendientes</p>
</section>
Debe tener un único hijo de texto, por eso usamos una plantilla y no ({pendientes}) Pedidos pendientes, que serían varios nodos. Medido en la 19.3.1, document.title vale (3) Pedidos pendientes; en la 18.3.1 sigue valiendo el de index.html.
Tabla de decisión
| Quieres… | En lugar de un efecto… |
|---|---|
| Mostrar un valor calculado | Calcúlalo en el render |
| Evitar repetir un cálculo caro | useMemo |
| Reiniciar todo el estado al cambiar una prop | key |
| Ajustar parte del estado | Guarda identificadores y deduce el resto |
| Reaccionar a un clic o a un envío | Manejador de eventos |
| Avisar al padre | Llama a su prop en el manejador |
| Compartir datos entre componentes | Eleva el estado (módulo 04) |
| Ejecutar algo una vez por carga | Código de módulo en main.tsx |
| Leer un almacén externo | useSyncExternalStore |
9. useEffectEvent: lógica que no reacciona 🆕 19.2
🆕 Desde React 19.2. No existe en React 19.1 ni anteriores: al llamarlo obtienes
useEffectEvent is not a function. Si usas una versión anterior: guarda la función en una ref actualizada en unuseLayoutEffect, como en el apartado “Si no tienes React 19.2” de esta sección.
Dentro de un efecto puede haber dos tipos de lógica mezclados. Parte es reactiva: si cambia, hay que volver a sincronizar. Otra parte es un evento: ocurre en un momento dado y solo quiere leer el valor actual de algo, sin que ese algo vuelva a lanzar el efecto.
Queremos conectarnos a una sala y mostrar un aviso con el tema visual actual al terminar de conectar. El aviso usa tema, así que tema debe estar en la lista:
// ❌ MAL: el aviso usa tema, y por eso cambiar de tema reconecta la sala
function Sala({ nombre, tema }: { nombre: string; tema: Tema }) {
useEffect(() => {
const conexion = crearConexion(nombre);
conexion.alConectar(() => console.log(`aviso con tema ${tema}: conectado a ${nombre}`));
conexion.conectar();
return () => conexion.desconectar();
}, [nombre, tema]);
return <p className="tarjeta">Sala {nombre}</p>;
}
conectar a VENTAS
aviso con tema claro: conectado a VENTAS
--- cambiar de tema ---
desconectar de VENTAS
conectar a VENTAS
aviso con tema oscuro: conectado a VENTAS
--- cambiar de sala ---
desconectar de VENTAS
conectar a MARKETING
aviso con tema oscuro: conectado a MARKETING
Cambiar el color de la interfaz ha cortado y restablecido la conexión. La lista no miente, pero el resultado es absurdo: el tema no tiene nada que ver con a qué sala estamos conectados. No es una dependencia real de la sincronización; solo la lee un evento que sale de ella.
📘 Concepto: Un efecto de evento (Effect Event) es una función creada con
useEffectEventque se llama desde un efecto, ve siempre las props y el estado más recientes y no forma parte de la lista de dependencias. Es el puente entre el mundo reactivo de los efectos y el mundo no reactivo de los eventos.
import { useEffect, useEffectEvent, useState } from 'react';
import { crearConexion } from './conexion';
type Tema = 'claro' | 'oscuro';
function Sala({ nombre, tema }: { nombre: string; tema: Tema }) {
// Efecto de evento: lee el tema actual, pero no hace que el efecto se repita
const avisarConexion = useEffectEvent(() => {
console.log(`aviso con tema ${tema}: conectado a ${nombre}`);
});
useEffect(() => {
const conexion = crearConexion(nombre);
conexion.alConectar(() => avisarConexion());
conexion.conectar();
return () => conexion.desconectar();
}, [nombre]);
return <p className="tarjeta">Sala {nombre}</p>;
}
export default function App() {
const [sala, setSala] = useState('VENTAS');
const [tema, setTema] = useState<Tema>('claro');
return (
<>
<div className="fila">
<button onClick={() => setSala(sala === 'VENTAS' ? 'MARKETING' : 'VENTAS')}>Cambiar de sala</button>
<button onClick={() => setTema(tema === 'claro' ? 'oscuro' : 'claro')}>Cambiar de tema</button>
</div>
<Sala nombre={sala} tema={tema} />
</>
);
}
conectar a VENTAS
aviso con tema claro: conectado a VENTAS
--- cambiar de tema ---
--- cambiar de sala ---
desconectar de VENTAS
conectar a MARKETING
aviso con tema oscuro: conectado a MARKETING
Al cambiar el tema ya no ocurre nada, y al cambiar de sala el aviso usa el tema oscuro, el más reciente. Por eso ha desaparecido tema de la lista sin que el linter proteste.
Otro caso: un temporizador con parámetros
El intervalo debe seguir su ritmo aunque cambie paso. Si paso estuviese en la lista, cada pulsación en el campo reiniciaría la cuenta:
import { useEffect, useEffectEvent, useState } from 'react';
export default function ContadorPaso() {
const [valor, setValor] = useState(0);
const [paso, setPaso] = useState(1);
const avanzar = useEffectEvent(() => {
console.log(`tic: valor=${valor}, paso=${paso}`);
setValor(valor + paso);
});
useEffect(() => {
const id = setInterval(() => avanzar(), 1000);
return () => clearInterval(id);
}, []);
return (
<section className="tarjeta">
<p>Valor: {valor}</p>
<label>
Paso:{' '}
<input type="number" value={paso} onChange={(e) => setPaso(Number(e.target.value))} />
</label>
</section>
);
}
tic: valor=0, paso=1
tic: valor=1, paso=1
--- paso = 10 ---
tic: valor=2, paso=10
tic: valor=12, paso=10
pantalla: Valor: 22
El temporizador se creó una vez, pero el efecto de evento lee el valor y el paso actuales en cada tic.
Reglas
| Regla | Si la incumples |
|---|---|
| Se llama solo desde efectos o desde otros efectos de evento del mismo componente | Lint: can only be called from Effects and Effect Events |
| No se llama durante el render | Error en ejecución: A function wrapped in useEffectEvent can't be called during rendering. |
| No se pasa a otros componentes ni a otros hooks | Lint: el mismo mensaje |
| No va en la lista de dependencias | Lint: must not be included in the dependency array. Su identidad cambia en cada render, así que el efecto se repetiría siempre |
| Se declara en el nivel superior | Es un hook: mismas reglas que los demás |
Si necesitas pasar un manejador a un hijo o usarlo en un onClick, escribe una función normal.
⚠️ Importante:
useEffectEventno es una forma de callar al linter. Esta versión compila, no da avisos y es incorrecta:
// ❌ MAL: esconde una dependencia real para que el aviso de lint desaparezca
function Sala({ nombre }: { nombre: string }) {
const conectar = useEffectEvent(() => {
const conexion = crearConexion(nombre);
conexion.conectar();
return () => conexion.desconectar();
});
useEffect(() => conectar(), []);
return <p className="tarjeta">Sala {nombre}</p>;
}
conectar a VENTAS
--- cambiar de sala ---
pantalla: Sala MARKETING
La pantalla dice MARKETING y seguimos conectados a VENTAS. El nombre sí es una dependencia real: decide a qué nos sincronizamos. Pregúntate siempre: si este valor cambia, ¿hay que volver a sincronizar? Si la respuesta es sí, va en la lista. Si solo quieres leer su valor actual cuando ocurre algo, es un evento.
Dentro de memo y forwardRef 🔄 19.3
🔄 Cambia en React 19.3. En React 19.2, un efecto de evento dentro de un componente envuelto en
memooforwardReflee valores antiguos. Si usas una versión anterior: quita elmemode ese componente, o usa el patrón de la ref del apartado siguiente, que no tiene este problema.
No es teórico. Este componente lee valor desde un efecto de evento:
import { memo, useEffect, useEffectEvent, useState } from 'react';
const Lector = memo(function Lector({ valor, disparo }: { valor: number; disparo: number }) {
const leer = useEffectEvent(() => {
console.log(`el efecto de evento lee valor=${valor}`);
});
useEffect(() => {
if (disparo > 0) leer();
}, [disparo]);
return <p className="tarjeta">valor {valor}, disparo {disparo}</p>;
});
export default function EventoMemo() {
const [valor, setValor] = useState(0);
const [disparo, setDisparo] = useState(0);
return (
<>
<div className="fila">
<button onClick={() => setValor(valor + 1)}>Sumar</button>
<button onClick={() => { setValor(valor + 1); setDisparo(disparo + 1); }}>Sumar y disparar</button>
</div>
<Lector valor={valor} disparo={disparo} />
</>
);
}
Pulsamos dos veces Sumar y disparar. En React 19.3.1:
el efecto de evento lee valor=1
pantalla: valor 1, disparo 1
el efecto de evento lee valor=2
pantalla: valor 2, disparo 2
Y en React 19.2.9, la última de esa rama cuando se escribió este módulo:
el efecto de evento lee valor=0
pantalla: valor 1, disparo 1
el efecto de evento lee valor=0
pantalla: valor 2, disparo 2
La pantalla muestra el valor correcto y el efecto de evento lee 0. Con forwardRef obtuvimos el mismo resultado en ambas versiones. Si usas la rama 19.2 con useEffectEvent, actualiza a 19.3 o vigila este caso.
Si no tienes React 19.2
El mismo comportamiento se consigue a mano: una ref guarda la última versión de la función, y el efecto llama a la ref. Se actualiza en un useLayoutEffect para que esté lista antes que cualquier useEffect:
export default function ContadorPasoRef() {
const [valor, setValor] = useState(0);
const [paso, setPaso] = useState(1);
function avanzar() {
console.log(`tic: valor=${valor}, paso=${paso}`);
setValor(valor + paso);
}
// Guarda la versión más reciente de la función después de cada render
const ultimoAvanzar = useRef(avanzar);
useLayoutEffect(() => {
ultimoAvanzar.current = avanzar;
});
useEffect(() => {
const id = setInterval(() => ultimoAvanzar.current(), 1000);
return () => clearInterval(id);
}, []);
Ejecutado con React 18.3.1 y 19.1.10, la salida es idéntica a la de useEffectEvent:
tic: valor=0, paso=1
tic: valor=1, paso=1
--- paso = 10 ---
tic: valor=2, paso=10
tic: valor=12, paso=10
pantalla: Valor: 22
Tiene dos desventajas: es más largo y las herramientas no te protegen de pasar la ref a otros componentes. A cambio, funciona en cualquier versión desde que existen los hooks.
10. useLayoutEffect: antes de pintar
useEffect deja pintar al navegador antes de ejecutarse, lo que normalmente es lo que quieres: la pantalla responde rápido. Pero hay un caso en que eso es un problema: cuando necesitas medir algo que acabas de renderizar y colocar otra cosa en función de la medida.
Queremos una burbuja de ayuda justo encima de un botón. Para colocarla encima necesitamos saber su altura, y eso no se sabe hasta que está en el DOM. Este ejemplo usa una ref para acceder al nodo, algo que verás a fondo en el módulo 10. Añade este estilo a index.css:
.burbuja {
pointer-events: none;
position: fixed;
max-width: 240px;
padding: 0.5rem 0.75rem;
border-radius: 6px;
background: #263238;
color: #fff;
font-size: 0.9rem;
}
pointer-events: none importa: sin esa línea, la burbuja colocada sobre el botón le quita el ratón, se dispara mouseleave y desaparece. Ahora el componente, primero con useEffect:
import { useEffect, useRef, useState, type ReactNode } from 'react';
function Burbuja({ ancla, children }: { ancla: DOMRect; children: ReactNode }) {
const burbuja = useRef<HTMLDivElement>(null);
const [alto, setAlto] = useState(0);
// ❌ useEffect: el navegador ya ha pintado la burbuja en el sitio equivocado
useEffect(() => {
setAlto(burbuja.current!.getBoundingClientRect().height);
}, []);
return (
<div ref={burbuja} className="burbuja" style={{ left: ancla.left, top: ancla.top - alto - 8 }}>
{children}
</div>
);
}
const ayudas = [
{ nombre: 'Ana', texto: 'Directora general desde 2010. Aprueba los presupuestos anuales de todos los departamentos.' },
{ nombre: 'Luis', texto: 'Director de ventas. Responsable de seis comerciales.' },
];
export default function App() {
const [activa, setActiva] = useState<{ nombre: string; texto: string; ancla: DOMRect } | null>(null);
return (
<div className="fila" style={{ marginTop: '6rem' }}>
{ayudas.map((a) => (
<button
key={a.nombre}
onMouseEnter={(e) => setActiva({ ...a, ancla: e.currentTarget.getBoundingClientRect() })}
onMouseLeave={() => setActiva(null)}
>
{a.nombre}
</button>
))}
{activa && <Burbuja key={activa.nombre} ancla={activa.ancla}>{activa.texto}</Burbuja>}
</div>
);
}
El primer render tiene alto = 0, así que la burbuja queda encima del botón, tapándolo. Después se mide y se corrige. Si el navegador llega a pintar entre ambos pasos, el usuario ve un salto.

Figura 11. Primer fotograma con alto = 0, congelado para poder fotografiarlo.

Figura 12. Resultado final, una vez medida la altura.
Basta con cambiar un hook para resolverlo:
// ✅ useLayoutEffect: mide y corrige antes de que el navegador pinte
useLayoutEffect(() => {
setAlto(burbuja.current!.getBoundingClientRect().height);
}, []);
useLayoutEffect tiene la misma firma que useEffect, pero se ejecuta sincrónicamente después de actualizar el DOM y antes de pintar. Cualquier cambio de estado dentro de él se aplica también antes de pintar, de modo que el usuario nunca ve el paso intermedio.
¿Se nota de verdad?
Lo medimos. Durante 20 aperturas, en una compilación de producción, leímos la posición de la burbuja en cada fotograma con requestAnimationFrame y contamos en cuántas aperturas hubo algún fotograma mal colocado:
| Cómo se abre | CPU | useEffect | useLayoutEffect |
|---|---|---|---|
| Ratón encima | Normal | 1 de 20 | 0 de 20 |
| Ratón encima | 6 veces más lenta (simulada) | 18 de 20 | 0 de 20 |
| Clic | Normal | 20 de 20 | 0 de 20 |
Tres lecciones de estos números:
useLayoutEffectno falló nunca. Es la garantía que compras.- Con
useEffect, el parpadeo depende del momento y de la máquina: en un ordenador rápido parece que todo va bien, y en el móvil de un usuario no. No lo puedes dar por resuelto porque “en tu equipo no se ve”. - Abrir con clic no salva a
useEffect. React puede ejecutar antes el efecto, pero, como avisa su documentación, puede dejar pintar antes de procesar las actualizaciones de estado hechas dentro. Aquí el primer fotograma salió mal en las 20 aperturas.
⚠️ Importante:
useLayoutEffectbloquea el pintado. Si haces algo lento dentro, la pantalla se congela. Úsalo solo para medir o colocar, y nunca para pedir datos. Empieza siempre poruseEffect.
Con renderizado en servidor 🔄 19.0
🔄 Cambia en React 19.0. En React 18.3 y anteriores,
renderToStringescribe en consolauseLayoutEffect does nothing on the server. Si usas una versión anterior: ignora el aviso si el contenido es igual en servidor y cliente, o usauseEffecten ese componente.
Los efectos no se ejecutan en el servidor, porque allí no hay DOM ni pantalla. Lo comprobamos con renderToString en cada versión: el aviso aparece en la 18.3.1 y ya no en la 19.0.9 ni posteriores. El efecto sigue sin ejecutarse en el servidor: solo se ha quitado el mensaje.
11. useInsertionEffect: para autores de librerías
Probablemente no lo escribirás nunca. Existe para resolver un problema concreto de las librerías de CSS-in-JS, las que generan estilos en tiempo de ejecución, y conviene saber qué hace para no confundirlo con los otros dos.
Figura 13. Los tres efectos respecto al pintado.
Si una librería inserta un <style> en un useLayoutEffect, otro useLayoutEffect que mida un elemento puede ejecutarse antes y medir sin esos estilos. useInsertionEffect se ejecuta antes que todos los efectos de layout, y evita el problema:
import { useEffect, useInsertionEffect, useLayoutEffect, useRef } from 'react';
function insertarEstilo(clase: string, regla: string) {
const estilo = document.createElement('style');
estilo.textContent = `.${clase} { ${regla} }`;
document.head.appendChild(estilo);
}
function CajaInsertion() {
const caja = useRef<HTMLDivElement>(null);
useInsertionEffect(() => {
console.log(`en useInsertionEffect la ref vale ${caja.current}`);
insertarEstilo('caja-a', 'width: 300px');
}, []);
useLayoutEffect(() => {
console.log(`con useInsertionEffect mide ${caja.current!.offsetWidth} px`);
}, []);
return <div ref={caja} className="caja-a" style={{ display: 'inline-block' }}>A</div>;
}
function CajaEfecto() {
const caja = useRef<HTMLDivElement>(null);
useEffect(() => {
insertarEstilo('caja-b', 'width: 300px');
}, []);
useLayoutEffect(() => {
console.log(`con useEffect mide ${caja.current!.offsetWidth} px`);
}, []);
return <div ref={caja} className="caja-b" style={{ display: 'inline-block' }}>B</div>;
}
export default function InsercionFase() {
return (
<>
<CajaInsertion />
<CajaEfecto />
</>
);
}
en useInsertionEffect la ref vale null
con useInsertionEffect mide 300 px
con useEffect mide 9 px
Las dos cajas deberían medir 300 px. La que inserta el estilo con useEffect mide 9, el ancho de su letra, porque el estilo aún no existe cuando se mide.
Este es el orden completo, con un padre y un hijo:
import { useEffect, useInsertionEffect, useLayoutEffect, useState } from 'react';
function Hijo({ valor }: { valor: number }) {
useInsertionEffect(() => {
console.log(`hijo ${valor}: insertion`);
return () => console.log(`hijo ${valor}: limpieza insertion`);
});
useLayoutEffect(() => {
console.log(`hijo ${valor}: layout`);
return () => console.log(`hijo ${valor}: limpieza layout`);
});
useEffect(() => {
console.log(`hijo ${valor}: efecto`);
return () => console.log(`hijo ${valor}: limpieza efecto`);
});
return <span>{valor}</span>;
}
export default function Orden() {
const [valor, setValor] = useState(1);
useLayoutEffect(() => {
console.log(`padre ${valor}: layout`);
});
useEffect(() => {
console.log(`padre ${valor}: efecto`);
});
return (
<button onClick={() => setValor(valor + 1)}>
<Hijo valor={valor} />
</button>
);
}
hijo 1: insertion
hijo 1: layout
padre 1: layout
hijo 1: efecto
padre 1: efecto
--- clic ---
hijo 1: limpieza insertion
hijo 2: insertion
hijo 1: limpieza layout
hijo 2: layout
padre 2: layout
hijo 1: limpieza efecto
hijo 2: efecto
padre 2: efecto
Observa cuatro cosas en la traza:
- Los hijos van antes que los padres dentro de cada fase.
- En cada fase, todas las limpiezas van antes que las ejecuciones nuevas, salvo en
useInsertionEffect, que limpia y monta componente a componente. - La fase
useEffectllega la última, después de todas las de layout. - La ref vale
nulldentro deuseInsertionEffect, como muestra la traza anterior: aún no se ha conectado al nodo.
Una librería usaría algo así:
import { useInsertionEffect, useState } from 'react';
const insertadas = new Set<string>();
// Versión mínima de lo que hace por dentro una librería de CSS-in-JS
function useCSS(clase: string, regla: string) {
useInsertionEffect(() => {
if (insertadas.has(clase)) return;
insertadas.add(clase);
const estilo = document.createElement('style');
estilo.textContent = `.${clase} { ${regla} }`;
document.head.appendChild(estilo);
}, [clase, regla]);
return clase;
}
function Etiqueta({ color, texto }: { color: string; texto: string }) {
const clase = useCSS(`etiqueta-${color}`, `color: ${color}; font-weight: bold`);
return <span className={clase}>{texto} </span>;
}
export default function UseCss() {
const [color, setColor] = useState('teal');
return (
<p className="tarjeta">
<Etiqueta color={color} texto="ACTIVO" />
<button onClick={() => setColor(color === 'teal' ? 'crimson' : 'teal')}>Cambiar color</button>
</p>
);
}
Comprobado en el navegador, el color cambia al pulsar y queda un <style> por cada color, sin duplicados. Pero hay límites estrictos:
useInsertionEffect | |
|---|---|
| Actualizar el estado | Prohibido. Error en consola: useInsertionEffect must not schedule updates. |
| Leer refs | No están conectadas todavía |
| Leer el DOM | No confíes en él: puede ejecutarse antes o después de su actualización |
| Ejecución | Solo en el cliente |
💡 Consejo: Para tus estilos normales usa lo que viste en el módulo 07: CSS Modules, Tailwind o
<style>en el componente. Este hook es una pieza de fontanería para otras herramientas.
12. Las reglas de lint
La lista de dependencias es tan fácil de equivocar que no conviene revisarla a mano. La plantilla de Vite trae oxlint, y ya está configurado para ello:
npm run lint
Con el Plantilla de la sección 3:
! react-hooks(exhaustive-deps): React Hook useEffect has a missing dependency: 'deptId'
,-[src/App.tsx:13:6]
9 | useEffect(() => {
10 | fetch(`${API}/empleados?deptId=${deptId}`)
: ^^^|^^
: `-- useEffect uses `deptId` here
11 | .then((res) => res.json())
12 | .then((datos) => setTotal(datos.total));
13 | }, []);
: ^^
14 |
`----
help: Either include it or remove the dependency array.
Found 1 warning and 0 errors.
No hace falta escribir la regla en .oxlintrc.json: react-hooks(exhaustive-deps) ya está activa con la configuración de la plantilla, que solo declara rules-of-hooks y only-export-components. Es un aviso y npm run lint termina con éxito; en integración continua, añade --deny-warnings para que falle.
Con el filtro que causaba el bucle de peticiones:
! react-hooks(exhaustive-deps): React hook useEffect depends on `filtro`, which changes every render
,-[src/App.tsx:16:7]
9 | const [empleados, setEmpleados] = useState<Empleado[]>([]);
10 | const filtro = { deptId, tamano: 5 };
: ^^^|^^
: `-- `filtro` is declared here
11 |
12 | useEffect(() => {
13 | fetch(`${API}/empleados?deptId=${filtro.deptId}&tamano=${filtro.tamano}`)
14 | .then((res) => res.json())
15 | .then((datos) => setEmpleados(datos.contenido));
16 | }, [filtro]);
: ^^^|^^
: `-- it will always cause this hook to re-evaluate
17 |
`----
help: Try memoizing this variable with `useRef` or `useCallback`.
Found 1 warning and 0 errors.
Con un estado que debería ser derivado, la regla más reciente:
! react(set-state-in-effect): Calling setState synchronously within an effect can trigger cascading renders
,-[src/App.tsx:16:5]
14 |
15 | useEffect(() => {
: ^^^^|^^^^
: `-- This is the containing effect
16 | setTotal(lineas.reduce((suma, l) => suma + l.precio * l.cantidad, 0));
: ^^^^|^^^
: `-- Avoid calling setState() directly within an effect
17 | }, [lineas]);
`----
help: Effects should synchronize React with external systems. Calling setState synchronously inside an effect starts another render and is usually unnecessary. Derive the value during render, initialize state directly, or update it from the event that caused the change. Use an effect only when synchronizing with an external system.
note: React Compiler skipped optimizing this component or hook. Additional guidance: https://react.dev/reference/eslint-plugin-react-hooks/lints/set-state-in-effect
Found 1 warning and 0 errors.
| Regla en oxlint | Qué detecta | Nivel por defecto |
|---|---|---|
react-hooks(exhaustive-deps) | Dependencias que faltan o que cambian en cada render, efectos async, efectos de evento en la lista | Aviso |
react-hooks(rules-of-hooks) | Hooks en condicionales o bucles, efectos de evento fuera de un efecto | Error |
react(set-state-in-effect) | setState directo en el cuerpo de un efecto | Aviso |
react(purity) | Date, Math.random y similares durante el render | Aviso |
set-state-in-effect no se queja de un setState dentro de un .then, de un temporizador ni de una función asíncrona definida en el efecto: el estado cambia cuando llega algo del exterior, y para eso están los efectos. Tampoco lo hizo al medir un nodo en useLayoutEffect. Sí señala setCargando(true) escrito directamente, y con razón, pero no es infalible: en la primera ficha de la sección 7 la llamada queda dentro de cargar() y no la detecta. Es otra razón para preferir el patrón de esa sección.
Con los efectos de evento mal usados (en la lista, llamados desde un manejador y pasados a un hijo), el linter responde así:
src/App.tsx:20:5: error react-hooks(rules-of-hooks): `registrar` is a function created with React Hook "useEffectEvent", and can only be called from Effects and Effect Events in the same component.
src/App.tsx:27:23: error react-hooks(rules-of-hooks): `registrar` is a function created with React Hook "useEffectEvent", and can only be called from Effects and Effect Events in the same component. It cannot be assigned to a variable or passed down.
src/App.tsx:17:7: warning react-hooks(exhaustive-deps): Functions returned from `useEffectEvent` must not be included in the dependency array. help: Remove the dependency from the dependency array.
ESLint
El plugin oficial se llama eslint-plugin-react-hooks, y en su versión 7.1.1 tiene las mismas reglas y más, varias de ellas heredadas de React Compiler (módulo 14). set-state-in-effect, por ejemplo, es un error en lugar de un aviso.
Pero hay un obstáculo: para leer TypeScript, ESLint necesita typescript-eslint, y su versión 8.72.1 declara que solo admite typescript <6.1.0. En un proyecto con TypeScript 7, npm install falla con ERESOLVE. Mientras eso no cambie, en este curso usamos oxlint. Si tu proyecto usa TypeScript 6 o anterior, esta configuración funciona (probada con TypeScript 6.0.3):
// eslint.config.js
import reactHooks from 'eslint-plugin-react-hooks';
import tseslint from 'typescript-eslint';
export default tseslint.config({
files: ['src/**/*.{ts,tsx}'],
extends: [reactHooks.configs.flat.recommended],
languageOptions: { parser: tseslint.parser },
});
Comparadas sobre los ejemplos de este módulo, ambas señalaron los mismos fallos, salvo un new Date() que solo vio oxlint, y las soluciones quedaron limpias en las dos.
No silencies el aviso
Es posible desactivar la regla en una línea:
// oxlint-disable-next-line react-hooks/exhaustive-deps
Casi siempre es un error. Si el linter protesta, el efecto ya está mal o lo estará en cuanto alguien lo toque, y además dejarás de recibir avisos sobre esa lista para siempre. Antes de silenciar, recorre estas salidas:
| El linter pide añadir… | Prueba a… |
|---|---|
| Un estado que solo se usa para calcular el siguiente | Función actualizadora: setN((n) => n + 1) |
| Un objeto o función del componente | Moverlo dentro del efecto o fuera del componente |
| Un valor que solo se lee cuando ocurre algo | useEffectEvent |
| Un valor que realmente cambia lo sincronizado | Añádelo: el linter tiene razón |
13. Caso práctico: panel de empleados 🆕 19.2
🆕 Desde React 19.2. Este caso usa
useEffectEvent, que no existe en React 19.1 ni anteriores. Si usas una versión anterior: sustituyerefrescarpor una ref actualizada enuseLayoutEffect, como en el apartado “Si no tienes React 19.2” de la sección 9. El resto funciona igual.
Vamos a reunir todo en una pantalla de la aplicación EMPRESA: un buscador de empleados con filtro por departamento que espera a que dejes de escribir, cancela lo que ya no sirve, se refresca solo cada 30 segundos y avisa si te quedas sin red.
import { useEffect, useEffectEvent, useState } from 'react';
const API = 'http://localhost:3000/api';
const REFRESCO_MS = 30_000;
const ESPERA_MS = 300;
type Departamento = { deptId: number; nombre: string };
type Empleado = {
empId: number;
nombre: string;
apellidos: string;
oficio: string;
departamento: string;
};
type Respuesta = { contenido: Empleado[]; total: number };
type Resultado = { clave: string; datos: Respuesta; hora: string } | { clave: string; error: string };
async function pedir<T>(ruta: string, signal: AbortSignal): Promise<T> {
const res = await fetch(`${API}${ruta}`, { signal });
const cuerpo = await res.json();
if (!res.ok) throw new Error(cuerpo.mensaje);
return cuerpo;
}
export default function PanelEmpleados() {
const [texto, setTexto] = useState('');
const [deptId, setDeptId] = useState('');
const [version, setVersion] = useState(0);
const [departamentos, setDepartamentos] = useState<Departamento[]>([]);
const [resultado, setResultado] = useState<Resultado | null>(null);
const [enLinea, setEnLinea] = useState(() => navigator.onLine);
// Lista desplegable: se carga una vez
useEffect(() => {
const control = new AbortController();
pedir<{ contenido: Departamento[] }>('/departamentos', control.signal)
.then((datos) => setDepartamentos(datos.contenido))
.catch(() => {}); // sin departamentos, el filtro se queda en "Todos"
return () => control.abort();
}, []);
// Búsqueda: espera a que dejes de escribir y cancela lo que ya no sirve
const consulta = new URLSearchParams({ q: texto, deptId, tamano: '6', orden: 'apellidos' }).toString();
const clave = `${consulta}#${version}`;
useEffect(() => {
const control = new AbortController();
const temporizador = setTimeout(() => {
pedir<Respuesta>(`/empleados?${consulta}`, control.signal)
.then((datos) => {
const hora = new Date().toLocaleTimeString('es-ES');
setResultado({ clave, datos, hora });
})
.catch((e: Error) => {
if (control.signal.aborted) return;
const mensaje = e instanceof TypeError ? 'No se puede contactar con la API' : e.message;
setResultado({ clave, error: mensaje });
});
}, ESPERA_MS);
return () => {
clearTimeout(temporizador);
control.abort();
};
}, [consulta, clave]);
// Estado de la red: suscripción a un sistema externo
useEffect(() => {
const actualizar = () => setEnLinea(navigator.onLine);
window.addEventListener('online', actualizar);
window.addEventListener('offline', actualizar);
return () => {
window.removeEventListener('online', actualizar);
window.removeEventListener('offline', actualizar);
};
}, []);
// Refresco periódico: lee enLinea sin reiniciar el temporizador
const refrescar = useEffectEvent(() => {
if (enLinea) setVersion((v) => v + 1);
});
useEffect(() => {
const id = setInterval(() => refrescar(), REFRESCO_MS);
return () => clearInterval(id);
}, []);
const buscando = resultado?.clave !== clave;
const datos = resultado && 'datos' in resultado ? resultado.datos : null;
return (
<section className="tarjeta">
<h2>Empleados</h2>
{!enLinea && <p className="error">Sin conexión de red.</p>}
<div className="fila">
<input
type="search"
placeholder="Buscar empleado"
aria-label="Buscar"
value={texto}
onChange={(e) => setTexto(e.target.value)}
/>
<select aria-label="Departamento" value={deptId} onChange={(e) => setDeptId(e.target.value)}>
<option value="">Todos</option>
{departamentos.map((d) => (
<option key={d.deptId} value={d.deptId}>{d.nombre}</option>
))}
</select>
</div>
{resultado && 'error' in resultado && !buscando && <p className="error">{resultado.error}</p>}
{datos && (
<table style={{ opacity: buscando ? 0.5 : 1 }}>
<thead>
<tr><th>N.º</th><th>Empleado</th><th>Oficio</th><th>Departamento</th></tr>
</thead>
<tbody>
{datos.contenido.map((e) => (
<tr key={e.empId}>
<td>{e.empId}</td>
<td>{e.apellidos}, {e.nombre}</td>
<td>{e.oficio}</td>
<td>{e.departamento}</td>
</tr>
))}
</tbody>
</table>
)}
<p className="tenue">
{buscando && 'Buscando...'}
{!buscando && datos && `${datos.total} ${datos.total === 1 ? 'empleado' : 'empleados'}`}
{!buscando && resultado && 'hora' in resultado && `, actualizado a las ${resultado.hora}`}
</p>
</section>
);
}

Figura 14. El panel filtrado por departamento.

Figura 15. Mientras llega la respuesta se conservan los datos anteriores, atenuados, sin parpadeo.
Cada efecto es un proceso independiente y responde a sus dos preguntas:
| Efecto | Empieza | Termina |
|---|---|---|
| Departamentos | Pide la lista | Cancela la petición |
| Búsqueda | Programa la petición con 300 ms de espera | Cancela la espera o la petición |
| Red | Escucha online y offline | Deja de escuchar |
| Refresco | Arranca un intervalo | Lo detiene |
Tres decisiones merecen un comentario:
- La espera y la cancelación van juntas. Cada pulsación cambia
consulta, React limpia el efecto anterior y elsetTimeoutpendiente se anula. Solo llega a ejecutarse el último. Es un debounce escrito en tres líneas, sin librerías. claveune consulta y versión. Sumar 1 aversioncambiaclave, y eso repite el efecto de búsqueda con los mismos filtros: es la forma de pedir “vuelve a cargar”. El resultado guarda suclave, así quebuscandose deduce sin un estado aparte.refrescares un efecto de evento. LeeenLinea, pero que cambie la red no debe reiniciar el contador de 30 segundos.
Probado en Chromium, con Strict Mode activo:
peticiones al abrir: ["/departamentos","/departamentos","/empleados"]
canceladas al abrir: 1
peticiones al escribir 6 letras: 1 /empleados?q=garcia&deptId=&tamano=6&orden=apellidos
pantalla: 100 García López, Ana DIRECTOR GENERAL DIRECCIÓN
estado: 1 empleado, actualizado a las 2:26:09
INFORMÁTICA: 6 empleados, actualizado a las 2:26:10
sin red: Sin conexión de red. | No se puede contactar con la API
Escribir garcia son seis pulsaciones y una sola petición. Al abrir hay dos peticiones de departamentos, una cancelada por Strict Mode, pero solo una de empleados: la primera ejecución del efecto se limpia antes de que venza la espera. Avanzando el reloj del navegador, el refresco periódico lanza una petición cada 30 s:
tras abrir, contador a cero: 0
tras 30 s: 1
tras 60 s: 2
14. Errores frecuentes
| Síntoma | Causa probable | Solución |
|---|---|---|
Maximum update depth exceeded | El efecto cambia un estado que lo vuelve a lanzar | Quita el setState o deriva el valor |
| Peticiones sin fin en Network, sin ningún error | Un objeto o función del cuerpo del componente en la lista | Depende de primitivos o muévelo |
| El contador se queda en 1 | Clausura vieja en un intervalo | Función actualizadora |
| El efecto se ejecuta dos veces solo en desarrollo | Strict Mode | Escribe la limpieza; no lo evites con una ref |
| Datos de otro elemento en pantalla | Condición de carrera | ignorar o AbortController en la limpieza |
| Aparece un error al cambiar rápido de pantalla | Tratas la cancelación como un fallo | Comprueba signal.aborted en el catch |
| Guardas el JSON de un error como si fueran datos | No compruebas res.ok | Lanza un error si no es correcto |
Argument of type '() => Promise<void>' | Efecto async | Función async dentro y llámala |
Type 'number' is not assignable to type 'void \| Destructor' | Flecha sin llaves que devuelve un valor | Pon llaves |
| El oyente sigue activo tras desmontar | removeEventListener con otra función | Guarda la función en una constante |
Un aviso o POST se repite al recargar | Lógica de evento dentro de un efecto | Muévela al manejador |
| Salto visual en una burbuja o menú | Medir con useEffect | useLayoutEffect |
El hook no se reconoce: is not a function | Versión de React anterior a la novedad | Comprueba npm ls react y la caja de esa sección |
💡 Consejo: Para depurar un efecto, escribe un
console.logal principio y otro en la limpieza, como en este módulo. La secuencia suele explicar el problema. Si quieres saber qué dependencia ha cambiado, escríbelas en consola en cada render. Y mira la pestaña Network con cualquier efecto que pida datos.
15. Ejercicios
✏️ Ejercicio: Todas las soluciones se han compilado y ejecutado con la API EMPRESA. Cada una sustituye a
src/App.tsx, salvo que se indique otra cosa. Intenta resolverlos antes de abrir la solución.
Ejercicio 1: Tamaño de la ventana
Muestra el ancho y el alto de la ventana y actualízalos al redimensionarla. Indica además si la vista es de móvil (menos de 768 px), tableta (menos de 1200 px) o escritorio.
Ver solución
import { useEffect, useState } from 'react';
function leerTamano() {
return { ancho: window.innerWidth, alto: window.innerHeight };
}
export default function TamanoVentana() {
const [tamano, setTamano] = useState(leerTamano);
useEffect(() => {
const actualizar = () => setTamano(leerTamano());
window.addEventListener('resize', actualizar);
return () => window.removeEventListener('resize', actualizar);
}, []);
const vista = tamano.ancho < 768 ? 'móvil' : tamano.ancho < 1200 ? 'tableta' : 'escritorio';
return <p className="tarjeta">{tamano.ancho} × {tamano.alto} px: vista de {vista}</p>;
}
Al redimensionar la ventana del navegador durante la prueba:
700 × 240 px: vista de móvil
1000 × 500 px: vista de tableta
1300 × 800 px: vista de escritorio
actualizar se guarda en una constante para poder quitar exactamente esa función. La lista vacía es correcta porque leerTamano está fuera del componente y las funciones set no cambian. useState(leerTamano) pasa la función sin llamarla para que React la ejecute solo la primera vez. vista no se guarda en el estado: se deduce.
Ejercicio 2: Cuenta atrás de la sesión
Muestra los segundos que quedan de sesión, partiendo de 5. Al llegar a cero, muestra “Sesión caducada” y detén el temporizador. Un botón “Seguir conectado” devuelve la cuenta a 5 y la reanuda si había terminado.
Ver solución
import { useEffect, useState } from 'react';
const DURACION = 5;
export default function CuentaAtras() {
const [restantes, setRestantes] = useState(DURACION);
const activa = restantes > 0;
useEffect(() => {
if (!activa) return;
const id = setInterval(() => setRestantes((r) => r - 1), 1000);
return () => clearInterval(id);
}, [activa]);
return (
<section className="tarjeta">
{activa ? <p>Tu sesión caduca en {restantes} s</p> : <p className="error">Sesión caducada</p>}
<button onClick={() => setRestantes(DURACION)}>Seguir conectado</button>
</section>
);
}
a los 2,3 s: Tu sesión caduca en 3 s
a los 5,5 s: Sesión caducada
tras renovar y 1,3 s: Tu sesión caduca en 4 s
El efecto no depende de restantes, que cambia cada segundo, sino del booleano activa, que solo cambia al llegar a cero o al renovar. Así, el intervalo no se destruye en cada tic. La función actualizadora evita la clausura vieja. Al caducar, activa pasa a false, React limpia el efecto y el intervalo se detiene.
Ejercicio 3: Detalle de un departamento
Muestra los datos de un departamento y sus empleados, con dos llamadas a la API en paralelo: /departamentos/:id y /departamentos/:id/empleados. Incluye botones para 10, 40, 70 y 99 (este último no existe). Si cambias de departamento antes de que termine la carga, hay que cancelar las dos peticiones.
Ver solución
import { useEffect, useState } from 'react';
const API = 'http://localhost:3000/api';
type Departamento = { deptId: number; nombre: string; localidad: string };
type Empleado = { empId: number; nombre: string; apellidos: string; oficio: string };
type Datos = { departamento: Departamento; empleados: Empleado[] };
type Resultado = { deptId: number; datos: Datos } | { deptId: number; error: string };
async function pedir<T>(ruta: string, signal: AbortSignal): Promise<T> {
const res = await fetch(`${API}${ruta}`, { signal });
const cuerpo = await res.json();
if (!res.ok) throw new Error(cuerpo.mensaje);
return cuerpo;
}
async function pedirDatos(deptId: number, signal: AbortSignal): Promise<Datos> {
const [departamento, plantilla] = await Promise.all([
pedir<Departamento>(`/departamentos/${deptId}`, signal),
pedir<{ contenido: Empleado[] }>(`/departamentos/${deptId}/empleados`, signal),
]);
return { departamento, empleados: plantilla.contenido };
}
function Detalle({ deptId }: { deptId: number }) {
const [resultado, setResultado] = useState<Resultado | null>(null);
useEffect(() => {
const control = new AbortController();
pedirDatos(deptId, control.signal)
.then((datos) => setResultado({ deptId, datos }))
.catch((e: Error) => {
if (!control.signal.aborted) setResultado({ deptId, error: e.message });
});
return () => control.abort();
}, [deptId]);
if (resultado?.deptId !== deptId) return <p className="tenue">Cargando...</p>;
if ('error' in resultado) return <p className="error">{resultado.error}</p>;
const { departamento, empleados } = resultado.datos;
return (
<>
<h2>{departamento.nombre} ({departamento.localidad})</h2>
<ul>
{empleados.map((e) => (
<li key={e.empId}>{e.nombre} {e.apellidos}: {e.oficio}</li>
))}
</ul>
</>
);
}
export default function App() {
const [deptId, setDeptId] = useState(40);
return (
<section className="tarjeta">
<div className="fila">
{[10, 40, 70, 99].map((d) => (
<button key={d} aria-pressed={d === deptId} onClick={() => setDeptId(d)}>{d}</button>
))}
</div>
<Detalle deptId={deptId} />
</section>
);
}

Figura 16. Solución del ejercicio 3.
10 | 40 | 70 | 99 | CONTABILIDAD (Sevilla) | Pedro Gómez Díaz: DIRECTOR | Isabel Domínguez Vega: CONTABLE | Jorge Vázquez Ramos: ADMINISTRATIVO
10 | 40 | 70 | 99 | INVESTIGACIÓN (Bilbao)
10 | 40 | 70 | 99 | | Departamento 99 no encontrado
canceladas: 2
Una sola señal se comparte entre las dos peticiones, así que abort() cancela ambas. Las líneas canceladas de la prueba corresponden a las dos peticiones del departamento 10, que quedaron anuladas al pulsar 70 enseguida. Si una petición falla, Promise.all rechaza y el mensaje de la API llega al catch. Fíjate en que la lista de empleados de la API viene paginada, dentro de contenido.
Ejercicio 4: Autoguardado
Crea un textarea cuyo contenido se guarde en localStorage un segundo después de dejar de escribir, no en cada pulsación. Debe indicar “Sin guardar…” o “Guardado”, y recuperar el texto al recargar la página.
Ver solución
import { useEffect, useState } from 'react';
const CLAVE = 'borrador-nota';
export default function Autoguardado() {
const [texto, setTexto] = useState(() => localStorage.getItem(CLAVE) ?? '');
const [guardado, setGuardado] = useState(texto);
const pendiente = texto !== guardado;
useEffect(() => {
if (!pendiente) return;
const temporizador = setTimeout(() => {
localStorage.setItem(CLAVE, texto);
setGuardado(texto);
}, 1000);
return () => clearTimeout(temporizador);
}, [texto, pendiente]);
return (
<section className="tarjeta">
<textarea rows={3} cols={40} value={texto} onChange={(e) => setTexto(e.target.value)} />
<p className="tenue">{pendiente ? 'Sin guardar...' : 'Guardado'}</p>
</section>
);
}
al terminar de escribir: Sin guardar... / guardado=null
1,3 s después: Guardado / guardado="Llamar a Luis"
tras recargar: "Llamar a Luis" Guardado
Cada pulsación cambia texto: React limpia el efecto, cancela el setTimeout pendiente y programa otro. pendiente se deduce comparando los dos estados. La condición if (!pendiente) return evita guardar justo después de cargar o de un guardado. El efecto puede devolver undefined en una rama y una limpieza en otra.
Ejercicio 5: Sobran los efectos
Este componente funciona, pero tiene tres efectos innecesarios. Refactorízalo para que no use ninguno, sin cambiar su comportamiento. Al cambiar el filtro hay que volver a la página 1.
import { useEffect, useState } from 'react';
type Producto = { id: number; nombre: string; disponible: boolean };
const productos: Producto[] = [
{ id: 1, nombre: 'Portátil', disponible: true },
{ id: 2, nombre: 'Monitor', disponible: false },
{ id: 3, nombre: 'Teclado', disponible: true },
{ id: 4, nombre: 'Ratón', disponible: true },
{ id: 5, nombre: 'Impresora', disponible: false },
];
const TAMANO = 2;
export default function ListaProductos() {
const [texto, setTexto] = useState('');
const [soloDisponibles, setSoloDisponibles] = useState(false);
const [visibles, setVisibles] = useState<Producto[]>([]);
const [paginas, setPaginas] = useState(1);
const [pagina, setPagina] = useState(1);
useEffect(() => {
setVisibles(
productos.filter(
(p) =>
p.nombre.toLowerCase().includes(texto.toLowerCase()) &&
(!soloDisponibles || p.disponible),
),
);
}, [texto, soloDisponibles]);
useEffect(() => {
setPaginas(Math.max(1, Math.ceil(visibles.length / TAMANO)));
}, [visibles]);
useEffect(() => {
setPagina(1);
}, [texto, soloDisponibles]);
const trozo = visibles.slice((pagina - 1) * TAMANO, pagina * TAMANO);
return (
<section className="tarjeta">
<div className="fila">
<input aria-label="Buscar" value={texto} onChange={(e) => setTexto(e.target.value)} />
<label>
<input
type="checkbox"
checked={soloDisponibles}
onChange={(e) => setSoloDisponibles(e.target.checked)}
/>{' '}
Solo disponibles
</label>
</div>
<ul>
{trozo.map((p) => (
<li key={p.id}>{p.nombre}</li>
))}
</ul>
<button disabled={pagina >= paginas} onClick={() => setPagina(pagina + 1)}>
Página {pagina} de {paginas}
</button>
</section>
);
}
Ver solución
import { useState } from 'react';
type Producto = { id: number; nombre: string; disponible: boolean };
const productos: Producto[] = [
{ id: 1, nombre: 'Portátil', disponible: true },
{ id: 2, nombre: 'Monitor', disponible: false },
{ id: 3, nombre: 'Teclado', disponible: true },
{ id: 4, nombre: 'Ratón', disponible: true },
{ id: 5, nombre: 'Impresora', disponible: false },
];
const TAMANO = 2;
export default function ListaProductos() {
const [texto, setTexto] = useState('');
const [soloDisponibles, setSoloDisponibles] = useState(false);
const [pagina, setPagina] = useState(1);
const visibles = productos.filter(
(p) =>
p.nombre.toLowerCase().includes(texto.toLowerCase()) &&
(!soloDisponibles || p.disponible),
);
const paginas = Math.max(1, Math.ceil(visibles.length / TAMANO));
const trozo = visibles.slice((pagina - 1) * TAMANO, pagina * TAMANO);
function cambiarTexto(valor: string) {
setTexto(valor);
setPagina(1);
}
function cambiarDisponibles(valor: boolean) {
setSoloDisponibles(valor);
setPagina(1);
}
return (
<section className="tarjeta">
<div className="fila">
<input aria-label="Buscar" value={texto} onChange={(e) => cambiarTexto(e.target.value)} />
<label>
<input
type="checkbox"
checked={soloDisponibles}
onChange={(e) => cambiarDisponibles(e.target.checked)}
/>{' '}
Solo disponibles
</label>
</div>
<ul>
{trozo.map((p) => (
<li key={p.id}>{p.nombre}</li>
))}
</ul>
<button disabled={pagina >= paginas} onClick={() => setPagina(pagina + 1)}>
Página {pagina} de {paginas}
</button>
</section>
);
}
Antes y después, en la misma secuencia de pruebas, el resultado es idéntico:
Portátil, Monitor / Página 1 de 3
siguiente: Teclado, Ratón / Página 2 de 3
buscar t: Portátil, Monitor / Página 1 de 2
solo disponibles: Portátil, Teclado / Página 1 de 2
visibles y paginas se calculan durante el render. Volver a la página 1 no es una sincronización: es parte de lo que ocurre cuando el usuario cambia el filtro, así que va en sus manejadores. Hemos quitado tres useEffect y dos estados, y npm run lint pasa de tres avisos a ninguno.
Ejercicio 6: Monitor de estadísticas 🆕 19.2
🆕 Desde React 19.2. Este ejercicio usa
useEffectEvent, que no existe en React 19.1 ni anteriores. Si usas una versión anterior: guarda la funciónalRecibiren una ref actualizada enuseLayoutEffect, como en la sección 9.
Consulta /estadisticas periódicamente y guarda en un historial las tres últimas lecturas del departamento elegido. Requisitos: se puede elegir la frecuencia (2 o 5 segundos), se puede pausar, y cambiar de departamento no debe reiniciar el temporizador ni provocar una petición.
Ver solución
import { useEffect, useEffectEvent, useState } from 'react';
const API = 'http://localhost:3000/api';
type FilaDepartamento = { deptId: number; nombre: string; empleados: number };
type Estadisticas = { porDepartamento: FilaDepartamento[] };
export default function Monitor() {
const [segundos, setSegundos] = useState(2);
const [pausado, setPausado] = useState(false);
const [deptId, setDeptId] = useState(20);
const [historial, setHistorial] = useState<string[]>([]);
// Es un evento: "ha llegado una lectura". Usa el departamento que se esté mirando
const alRecibir = useEffectEvent((datos: Estadisticas) => {
const fila = datos.porDepartamento.find((d) => d.deptId === deptId);
const hora = new Date().toLocaleTimeString('es-ES');
setHistorial((h) => [`${hora} ${fila?.nombre}: ${fila?.empleados} empleados`, ...h].slice(0, 3));
});
useEffect(() => {
if (pausado) return;
const control = new AbortController();
async function consultar() {
try {
const res = await fetch(`${API}/estadisticas`, { signal: control.signal });
alRecibir(await res.json());
} catch (e) {
if (!control.signal.aborted) console.error(e);
}
}
consultar();
const id = setInterval(consultar, segundos * 1000);
return () => {
clearInterval(id);
control.abort();
};
}, [segundos, pausado]);
return (
<section className="tarjeta">
<div className="fila">
<select aria-label="Cada" value={segundos} onChange={(e) => setSegundos(Number(e.target.value))}>
<option value={2}>Cada 2 s</option>
<option value={5}>Cada 5 s</option>
</select>
<select aria-label="Departamento" value={deptId} onChange={(e) => setDeptId(Number(e.target.value))}>
<option value={20}>VENTAS</option>
<option value={30}>INFORMÁTICA</option>
<option value={40}>CONTABILIDAD</option>
</select>
<button aria-pressed={pausado} onClick={() => setPausado(!pausado)}>Pausa</button>
</div>
<ul>
{historial.map((linea, i) => (
<li key={i}>{linea}</li>
))}
</ul>
</section>
);
}

Figura 17. Solución del ejercicio 6.
peticiones a los 2,5 s: 3
tras cambiar de departamento: 3
2 s más: 4
1:33:19 INFORMÁTICA: 6 empleados | 1:33:17 VENTAS: 6 empleados | 1:33:15 VENTAS: 6 empleados
en pausa, peticiones nuevas: 0
al reanudar: 1
segundos y pausado deciden cómo se sincroniza, así que van en la lista. deptId solo se lee cuando llega una lectura: es un evento. Por eso, tras cambiar de departamento, el número de peticiones no sube. Las dos primeras peticiones se deben a Strict Mode. La primera lectura tras reanudar es inmediata porque el efecto llama a consultar() antes de programar el intervalo.
Ejercicio 7: Menú contextual
Al pulsar con el botón derecho en una zona, muestra un menú propio en ese punto. Debe cerrarse con Escape o al pulsar fuera, y si no cabe en la ventana debe desplazarse hacia dentro sin que se vea el salto. Para los estilos, añade esto a index.css:
.zona {
min-height: 260px;
border: 2px dashed #c5cae9;
border-radius: 10px;
padding: 1rem;
color: #607d8b;
}
.menu {
white-space: nowrap;
position: fixed;
margin: 0;
padding: 0.25rem 0;
list-style: none;
background: #fff;
border: 1px solid #3f51b5;
border-radius: 6px;
box-shadow: 0 4px 12px rgb(0 0 0 / 20%);
color: #263238;
}
.menu li {
padding: 0.3rem 1rem;
}
Ver solución
import { useEffect, useLayoutEffect, useRef, useState } from 'react';
type Punto = { x: number; y: number };
function Menu({ punto, alCerrar }: { punto: Punto; alCerrar: () => void }) {
const menu = useRef<HTMLUListElement>(null);
const [ajuste, setAjuste] = useState<Punto>({ x: 0, y: 0 });
// Antes de pintar: si no cabe, lo desplaza hacia dentro de la ventana
useLayoutEffect(() => {
const { width, height } = menu.current!.getBoundingClientRect();
setAjuste({
x: Math.min(0, window.innerWidth - punto.x - width - 8),
y: Math.min(0, window.innerHeight - punto.y - height - 8),
});
}, [punto]);
// Mientras esté abierto: escucha fuera del árbol de React
useEffect(() => {
function pulsarFuera(e: MouseEvent) {
if (!menu.current?.contains(e.target as Node)) alCerrar();
}
function pulsarTecla(e: KeyboardEvent) {
if (e.key === 'Escape') alCerrar();
}
document.addEventListener('mousedown', pulsarFuera);
document.addEventListener('keydown', pulsarTecla);
return () => {
document.removeEventListener('mousedown', pulsarFuera);
document.removeEventListener('keydown', pulsarTecla);
};
}, [alCerrar]);
return (
<ul ref={menu} className="menu" style={{ left: punto.x + ajuste.x, top: punto.y + ajuste.y }}>
<li>Ver ficha</li>
<li>Editar empleado</li>
<li>Enviar correo</li>
<li>Dar de baja</li>
</ul>
);
}
export default function App() {
const [punto, setPunto] = useState<Punto | null>(null);
return (
<div
className="zona"
onContextMenu={(e) => {
e.preventDefault();
setPunto({ x: e.clientX, y: e.clientY });
}}
>
Pulsa con el botón derecho en cualquier punto
{punto && <Menu punto={punto} alCerrar={() => setPunto(null)} />}
</div>
);
}

Figura 18. Pulsando cerca de la esquina, el menú se desplaza para caber.
en (100,100): {"x":100,"y":100,"width":149.640625,"height":132.375}
Escape: menús abiertos 0
en la esquina (660,300): {"x":542.359375,"y":199.625,"width":149.640625,"height":132.375}
clic fuera: menús abiertos 0
Hay dos efectos porque son dos procesos distintos. Colocar el menú depende de medirlo antes de pintar, así que usa useLayoutEffect. Escuchar clics y teclas no toca la pantalla, así que basta useEffect. Como alCerrar llega desde el padre como función nueva en cada render de este, el efecto puede volver a suscribirse; es inofensivo y la lista es sincera.
Atención a white-space: nowrap: en la primera versión de esta solución no estaba, y en la esquina el menú medía 112 px en lugar de 150. Con poco espacio a la derecha, un elemento fixed encoge su contenido, y estábamos midiendo esa versión encogida. Comprueba siempre las medidas en el caso difícil.
Ejercicio 8 (propuesto): Lista infinita
Muestra los pedidos de /api/pedidos de 5 en 5. Coloca un elemento centinela al final de la lista y usa un IntersectionObserver para cargar la página siguiente cuando aparezca. Usa una ref para el centinela (módulo 10), desconecta el observador en la limpieza, cancela la petición en curso y deja de observar cuando no queden páginas.
Ejercicio 9 (propuesto): Reintentos con espera creciente
Escribe una ficha de producto que, si fetch falla por un error de red o un 5xx, reintente a los 1, 2 y 4 segundos, mostrando “Reintentando (2 de 3)…”. Al cambiar de producto se deben cancelar tanto la petición en curso como el temporizador del reintento. Pruébalo parando la API mientras la pantalla está abierta.
Ejercicio 10 (propuesto): Refresco solo con la pestaña visible
Parte del ejercicio 6 y detén las consultas mientras la pestaña esté oculta, escuchando el evento visibilitychange de document. Al volver, haz una consulta inmediata. Razona si visible debe ser una dependencia del efecto o leerse con un efecto de evento, y comprueba tu respuesta.
Resumen
| Concepto | API | Idea clave |
|---|---|---|
| Efecto | useEffect(fn, deps) | Sincroniza con un sistema externo tras actualizar la pantalla |
| Render puro | Cuerpo del componente | Nada de arrancar cosas: React decide cuándo se ejecuta |
| Dependencias | [a, b] | Describen lo que lee el código; se comparan con Object.is |
| Valor reactivo | Props, estado, variables del componente | Si el efecto lo lee, va en la lista |
| Clausura vieja | setN((n) => n + 1) | Deja de leer el valor en lugar de omitirlo |
| Limpieza | return () => ... | Deshace el efecto; ve los valores antiguos |
| Ciclo de vida | Empezar y dejar de sincronizar | Cada efecto es un proceso independiente |
| Strict Mode | <StrictMode> | Montaje, limpieza y montaje en desarrollo; detecta limpiezas que faltan |
| Hidratación y Fast Refresh | 🔄 19.3 | Strict Mode repite los efectos también ahí |
| Efectos ocultos | <Activity> 🆕 19.2 | Conserva el estado y apaga los efectos |
| Datos | fetch con res.ok | Comprueba la respuesta y guarda a qué id pertenece |
| Condición de carrera | Bandera ignorar | Descarta la respuesta antigua |
| Cancelación | AbortController, AbortSignal.timeout, AbortSignal.any | Libera la red; no es un error |
| Efecto innecesario | Render, evento, key, useSyncExternalStore | Sin sistema externo, sobra |
| Título | <title> 🆕 19.0 | Ya no necesita efecto |
| Efecto de evento | useEffectEvent 🆕 19.2 | Lee lo más reciente sin ser dependencia |
| Antes de pintar | useLayoutEffect | Medir y colocar sin salto; bloquea el pintado |
| Estilos | useInsertionEffect | Solo para librerías de CSS-in-JS |
| Lint | exhaustive-deps, rules-of-hooks, set-state-in-effect | Vienen activas en oxlint; no las silencies |