Módulo 6: Effects And Side Effects
El cleanup: deshacer lo que montaste
Descripción
Un efecto que conecta algo tiene que saber desconectarlo. Si tu efecto abre una conexión, arranca un temporizador o agrega un listener al window, tarde o temprano hay que cerrar esa conexión, parar ese temporizador y quitar ese listener —porque el componente se va de la pantalla, o porque una dependencia cambió y hay que reconectar con el valor nuevo—. React te da un lugar exacto para eso: el efecto puede devolver una función, y esa función es el cleanup. React la corre en dos momentos: antes de volver a ejecutar el efecto (cuando una dependencia cambió) y cuando el componente se desmonta. Esta lección construye el cleanup, lo mide con una suscripción que se mantiene siempre en exactamente una conexión, y muestra qué pasa cuando falta (leaks).
Conexión con el módulo. Es la pareja de la lección 3. Allá viste cuándo un efecto vuelve a correr (cuando cambia una dependencia); aquí ves que, cada vez que vuelve a correr, primero deshace lo anterior. Setup y cleanup son las dos mitades de un efecto: conectar y desconectar. Y es la base directa de la lección 6: la race condition del fetch se resuelve con un cleanup que marca la respuesta vieja como obsoleta, y el doble efecto de StrictMode existe justo para destapar los efectos a los que les falta esta pieza.
Una analogía: desconectar el aparato al salir (y cancelar la revista)
Vuelve al ventilador. El setup era conectarlo al enchufe al entrar al cuarto. El cleanup es lo que hace un adulto responsable al salir: desconectarlo. Si nunca desconectas, el ventilador sigue girando en el cuarto vacío, gastando luz, para nadie. Y cuando te mudas de cuarto, no dejas el de acá girando y enciendes el de allá encima: primero desconectas el viejo, luego conectas el nuevo. Ese orden —desconectar lo viejo antes de conectar lo nuevo— es exactamente el que React sigue cuando una dependencia cambia.
La segunda imagen, aún más nítida para las suscripciones: la suscripción a una revista. Te suscribes (setup): empiezan a llegarte ejemplares. Si te mudas y no cancelas (cleanup), la revista sigue llegando a tu dirección vieja —y te la siguen cobrando— para siempre. Cancelar la suscripción vieja antes de suscribirte en la dirección nueva es el cleanup. Un efecto sin cleanup es una suscripción que nunca se cancela: se acumulan, cuestan, y ensucian. Guarda las dos: el cleanup desconecta el aparato y cancela la revista. Es la función que devuelve el efecto, y no es opcional cuando el setup dejó algo abierto.
Ejemplo trabajado: una suscripción, siempre exactamente una activa
El caso de libro del cleanup es una suscripción a un sistema externo. Imagina un PriceWidget que muestra el precio en vivo de un producto: al montarse, se suscribe al canal de precios de ese productId; cuando el usuario cambia de producto, hay que cancelar la suscripción vieja y abrir la del producto nuevo; y al desmontarse el widget, hay que cancelar la que quede. El componente como lo escribirás en React:
function PriceWidget({ productId }) {
useEffect(() => {
const subscription = priceChannel.subscribe(productId); // setup: conecta
return () => {
subscription.unsubscribe(); // cleanup: desconecta
};
}, [productId]); // re-sincroniza cuando cambia el producto
return (/* el precio en vivo */);
}
Fíjate en la forma: el efecto devuelve una función (return () => { ... }). Esa función es el cleanup. React la guarda y la corre en el momento correcto. Ahora ejecutémoslo. Modelamos un contador activeSubscriptions que representa cuántas suscripciones hay abiertas en el "servidor externo", y corremos una secuencia: montar con p1, cambiar a p2, cambiar a p3, y desmontar. En cada paso imprimimos el setup y el cleanup, y cuántas quedan activas:
// runtime de effects CON cleanup: useState, sameDeps y useEffect como en la leccion 3,
// con DOS cambios para soportar el cleanup:
// 1) useEffect conserva en la ranura del hook el cleanup ANTERIOR (cleanup: prev?.cleanup),
// para que no se pierda al re-registrar el efecto en cada render;
// 2) commit corre ese cleanup ANTERIOR antes del nuevo setup, y guarda el que devuelve.
let effectHooks = []; // una ranura por cada useEffect, en orden de llamada
let hookCursor = 0;
let pending = [];
let stateCells = [];
let stateCursor = 0;
let rerender = () => {};
function useState(initial) {
const i = stateCursor++;
if (!(i in stateCells)) stateCells[i] = initial;
const setState = (next) => {
const value = typeof next === 'function' ? next(stateCells[i]) : next;
if (Object.is(value, stateCells[i])) return; // React NO re-renderiza si el valor es el mismo
stateCells[i] = value;
rerender();
};
return [stateCells[i], setState];
}
function sameDeps(a, b) {
if (a === undefined || b === undefined) return false;
if (a.length !== b.length) return false;
for (let k = 0; k < a.length; k++) if (!Object.is(a[k], b[k])) return false;
return true;
}
function useEffect(setup, deps) {
const i = hookCursor++;
const prev = effectHooks[i];
let shouldRun;
if (prev === undefined) shouldRun = true; // primer montaje: siempre corre
else if (deps === undefined) shouldRun = true; // sin array: cada render
else shouldRun = !sameDeps(prev.deps, deps); // con array: solo si cambio una dep
// NUEVO vs leccion 3: conservamos el cleanup anterior en la ranura, para que commit lo corra
effectHooks[i] = { setup, deps, cleanup: prev && prev.cleanup };
if (shouldRun) pending.push(i);
}
function commit() {
for (const i of pending) {
const hook = effectHooks[i];
if (hook.cleanup) hook.cleanup(); // el cleanup ANTERIOR corre antes del nuevo setup
const cleanup = hook.setup();
hook.cleanup = typeof cleanup === 'function' ? cleanup : undefined;
}
pending = [];
}
function unmount() {
for (const hook of effectHooks) if (hook && hook.cleanup) hook.cleanup();
}
let activeSubscriptions = 0; // suscripciones abiertas en el "servidor externo"
let ui;
function PriceWidget() {
const [productId, setProductId] = useState('p1');
useEffect(() => {
activeSubscriptions++;
console.log(` [setup] subscribe(${productId}) activas ahora: ${activeSubscriptions}`);
return () => {
activeSubscriptions--;
console.log(` [cleanup] unsubscribe(${productId}) activas ahora: ${activeSubscriptions}`);
};
}, [productId]);
return { setProductId };
}
function renderAndCommit() { hookCursor = 0; stateCursor = 0; ui = PriceWidget(); commit(); }
rerender = renderAndCommit;
console.log('=== Cleanup: siempre exactamente una suscripcion activa ===\n');
console.log('montar (productId = p1):');
renderAndCommit();
console.log('\ncambiar a p2 (cleanup de p1, luego setup de p2):');
ui.setProductId('p2');
console.log('\ncambiar a p3 (cleanup de p2, luego setup de p3):');
ui.setProductId('p3');
console.log('\ndesmontar el componente (corre el ultimo cleanup):');
unmount();
console.log(`\nSuscripciones colgando al final: ${activeSubscriptions}`);
Qué esperar. Al correr el archivo con Node, la salida es exactamente esta:
=== Cleanup: siempre exactamente una suscripcion activa ===
montar (productId = p1):
[setup] subscribe(p1) activas ahora: 1
cambiar a p2 (cleanup de p1, luego setup de p2):
[cleanup] unsubscribe(p1) activas ahora: 0
[setup] subscribe(p2) activas ahora: 1
cambiar a p3 (cleanup de p2, luego setup de p3):
[cleanup] unsubscribe(p2) activas ahora: 0
[setup] subscribe(p3) activas ahora: 1
desmontar el componente (corre el ultimo cleanup):
[cleanup] unsubscribe(p3) activas ahora: 0
Suscripciones colgando al final: 0
Lee la columna activas ahora: nunca pasa de 1. Esa es la lección entera.
Al montar con p1, el setup corre y abre una suscripción: activas = 1. Al cambiar a p2, mira el orden: primero el [cleanup] de p1 (unsubscribe(p1), activas = 0), y luego el [setup] de p2 (subscribe(p2), activas = 1). React desconecta lo viejo antes de conectar lo nuevo —exactamente como al mudarte de cuarto—. Cambiar a p3 repite el patrón: cleanup de p2, setup de p3. Y al desmontar, corre el último [cleanup] (unsubscribe(p3), activas = 0): el widget se fue de la pantalla y no dejó ninguna suscripción colgando. El total al final es 0: cero leaks.
Ese "siempre exactamente una activa" es lo que garantiza el cleanup. Sin él, cada cambio de producto abriría una suscripción nueva sin cerrar la anterior, y el número solo subiría. Veámoslo.
Profundización: sin cleanup, y cuándo lo necesitas
Qué pasa sin cleanup. Tomemos el mismo PriceWidget, pero con un efecto que no devuelve nada —olvidamos el cleanup—:
useEffect(() => {
activeSubscriptions++;
console.log(` [setup] subscribe(${productId}) activas ahora: ${activeSubscriptions}`);
// SIN cleanup: no devolvemos nada
}, [productId]);
Corriendo la misma secuencia (montar p1, cambiar a p2, cambiar a p3), Qué esperar es:
=== SIN cleanup: las suscripciones se acumulan (leak) ===
[setup] subscribe(p1) activas ahora: 1
[setup] subscribe(p2) activas ahora: 2
[setup] subscribe(p3) activas ahora: 3
Suscripciones colgando al final: 3 <- deberian ser 1
El número solo sube: 1 → 2 → 3. Cada cambio de producto abrió una suscripción nueva y dejó las viejas abiertas. Al final hay tres suscripciones activas cuando debería haber una: el widget recibe actualizaciones de precios de p1, p2 y p3 a la vez, mezcladas, gastando red y memoria. Eso es un leak: recursos que se abren y nunca se cierran. En una app real esto se manifiesta como lentitud creciente, datos mezclados, o el infame "warning" de actualizar el estado de un componente desmontado. La revista que nunca cancelaste, multiplicada.
Los tres momentos en que corre el cleanup. Precisemos, porque es donde la gente se confunde:
- Antes de re-ejecutar el efecto (cuando una dependencia cambió). React corre el cleanup del render anterior y luego el setup nuevo. Desconecta lo viejo, conecta lo nuevo.
- Al desmontar el componente (cuando desaparece de la pantalla). Corre el cleanup una última vez, para no dejar nada abierto.
- En desarrollo con StrictMode, un extra: React monta, corre el efecto, corre el cleanup y vuelve a montar (setup → cleanup → setup) para verificar que tu cleanup deshace bien lo que el setup hizo. Es la lección 6; por ahora quédate con que un cleanup correcto sobrevive esa prueba sin dejar nada colgando.
Qué debe deshacer el cleanup: exactamente lo que el setup montó. La regla de oro es la simetría. Si el setup hizo addEventListener, el cleanup hace removeEventListener. Si el setup abrió una conexión, el cleanup la cierra. Si el setup llamó a setInterval, el cleanup llama a clearInterval. Si el setup se suscribió, el cleanup cancela. El cleanup no es "código de limpieza genérico"; es el espejo del setup.
// Patron: cada cosa que el setup ABRE, el cleanup la CIERRA.
useEffect(() => {
function handleResize() { /* ... */ }
window.addEventListener('resize', handleResize); // setup: agrega
return () => window.removeEventListener('resize', handleResize); // cleanup: quita el MISMO
}, []);
useEffect(() => {
const id = setInterval(tick, 1000); // setup: arranca el timer
return () => clearInterval(id); // cleanup: para ESE timer
}, []);
Cuándo NO hace falta cleanup. No todo efecto necesita cleanup. Si el setup no deja nada abierto —por ejemplo, document.title = name solo escribe un valor, no abre una conexión ni agrega un listener—, no hay nada que deshacer, y el efecto no devuelve función. La pregunta para decidir: ¿el setup abrió, arrancó o se suscribió a algo que quede vivo después? Si sí, necesitas cleanup. Si solo escribió un valor de una vez, no.
Errores comunes
Olvidar el cleanup de un efecto que abre algo (leak). Qué pasa: el efecto hace subscribe, addEventListener, setInterval o fetch, pero no devuelve función; las conexiones/listeners/timers se acumulan. Por qué pasa: el setup "funciona" a la vista, y el leak es invisible hasta que se acumula. Cómo detectarlo: la app se pone lenta con el uso; ves listeners o conexiones duplicados; aparece el warning de actualizar estado de un componente desmontado. Cómo corregirlo: devuelve un cleanup que deshaga exactamente lo que el setup montó (su espejo). Si el setup abre, el cleanup cierra.
Cleanup asimétrico (no deshace lo mismo). Qué pasa: el setup hace addEventListener('resize', handleResize) pero el cleanup hace removeEventListener('scroll', otraFuncion) —quita algo distinto—, y el listener original nunca se remueve. Por qué pasa: copiar-pegar, o cambiar el setup sin actualizar el cleanup. Cómo detectarlo: el listener sigue activo después de desmontar; el removeEventListener no tiene efecto (porque la función no es la misma referencia). Cómo corregirlo: el cleanup debe referenciar la misma función/id/conexión que el setup. Para removeEventListener, debe ser la misma referencia de función (por eso handleResize se define dentro del efecto y se usa en ambos).
Poner el cleanup como una función suelta, no como el return del efecto. Qué pasa: se define una función de limpieza pero se llama en otro lado (o nunca), en vez de devolverla desde el efecto. Por qué pasa: no se tiene claro que React espera que el efecto retorne el cleanup. Cómo detectarlo: la limpieza nunca corre en los momentos correctos. Cómo corregirlo: el cleanup es lo que el efecto retorna: useEffect(() => { /* setup */ return () => { /* cleanup */ }; }, [...]). React se encarga de llamarlo; tú solo lo devuelves.
Ejercicios
Ejercicio 1 — Escribe el cleanup. Este efecto arranca un temporizador que hace tick cada segundo, pero le falta el cleanup. Complétalo:
useEffect(() => {
const id = setInterval(() => tick(), 1000);
// ??? falta el cleanup
}, []);
Ver solución
El setup arrancó un setInterval, que deja un temporizador vivo. El cleanup debe pararlo con clearInterval, usando el id que devolvió el setup:
useEffect(() => {
const id = setInterval(() => tick(), 1000);
return () => clearInterval(id); // cleanup: para ESE timer
}, []);
Sin este cleanup, cada montaje (o cada cambio de dependencia) arrancaría un timer nuevo sin parar los viejos: tick se llamaría dos, tres, cuatro veces por segundo, acumulándose. El cleanup es el espejo del setup: setInterval → clearInterval, con el mismo id.
Ejercicio 2 — Traza el orden. Para el PriceWidget con cleanup, escribe la secuencia exacta de [setup] y [cleanup] (con el productId) si el usuario hace: montar con p1, cambiar a p2, y luego desmontar (sin pasar por p3). Di cuántas suscripciones quedan al final.
Ver solución
montar p1: [setup] subscribe(p1) activas: 1
cambiar a p2: [cleanup] unsubscribe(p1) activas: 0
[setup] subscribe(p2) activas: 1
desmontar: [cleanup] unsubscribe(p2) activas: 0
- Montar
p1: solo setup (no hay nada anterior que limpiar).activas = 1. - Cambiar a
p2: la dependenciaproductIdcambió, así que React corre primero el cleanup del efecto viejo (unsubscribe(p1),activas = 0) y luego el setup nuevo (subscribe(p2),activas = 1). Desconecta lo viejo antes de conectar lo nuevo. - Desmontar: corre el último cleanup (
unsubscribe(p2),activas = 0).
Al final quedan 0 suscripciones: el cleanup cerró todo. En cada momento hubo como máximo una activa.
Ejercicio 3 — ¿Necesita cleanup? Para cada efecto, di si necesita cleanup y, si sí, cuál sería: (a) document.title = name; (b) window.addEventListener('keydown', handleKey); (c) analytics.track('view', id); (d) const conn = createConnection(roomId); conn.connect();
Ver solución
- (a) No necesita cleanup.
document.title = namesolo escribe un valor; no abre ni arranca nada que quede vivo. No hay nada que deshacer. (React lo re-sincroniza en el próximo efecto si el nombre cambia.) - (b) Sí.
addEventListenerdeja un listener vivo. Cleanup:return () => window.removeEventListener('keydown', handleKey);con la misma referencia dehandleKey. - (c) No necesita cleanup.
trackdispara un evento de una vez y termina; no deja nada abierto. (Su asunto es correr una vez por vista, que se controla con el array de dependencias, no con cleanup.) - (d) Sí.
createConnection(...).connect()abre una conexión viva. Cleanup:return () => conn.disconnect();para cerrar esa misma conexión.
La pregunta que decide: ¿el setup dejó algo abierto/vivo? (a) y (c) escriben o disparan de una vez → sin cleanup. (b) y (d) abren algo que persiste → con cleanup, espejo del setup.
Resumen y siguiente paso
En esta lección construiste la otra mitad de un efecto: el cleanup, la función que el efecto devuelve para deshacer lo que el setup montó. React la corre en tres momentos: antes de re-ejecutar el efecto (cuando una dependencia cambió, desconectando lo viejo antes de conectar lo nuevo), al desmontar (para no dejar nada colgando) y, en desarrollo, en la prueba del doble efecto de StrictMode. Lo mediste con una suscripción que, gracias al cleanup, se mantuvo siempre en exactamente una activa a lo largo de tres cambios de producto y terminó en cero; y viste que sin cleanup el número solo sube (1 → 2 → 3: un leak). Y guardaste la regla de oro: el cleanup es el espejo del setup —addEventListener/removeEventListener, subscribe/unsubscribe, setInterval/clearInterval—, y solo hace falta cuando el setup dejó algo abierto. El aparato que conectas al entrar, lo desconectas al salir; la revista a la que te suscribes, la cancelas al mudarte.
Antes de avanzar deberías poder: escribir el cleanup de un efecto que abre una conexión, un listener o un timer; explicar los tres momentos en que corre; trazar el orden cleanup-viejo → setup-nuevo cuando cambia una dependencia; y decidir si un efecto necesita cleanup o no.
La lección 5 lleva todo esto al caso más pedido: el fetch de datos. El App de Mercado, al montarse, pedirá los productos a la red y los guardará en estado; verás el patrón crudo —products en null (cargando) → efecto que pide → setProducts → re-render con la lista—, ejecutado con una red simulada. Y ahí quedará planteada la trampa que la lección 6 resuelve con el cleanup que acabas de aprender.
Recursos
- React, "Synchronizing with Effects" — react.dev/learn/synchronizing-with-effects. La sección "How to handle the Effect firing twice in development" y el ejemplo de suscripción con cleanup; la referencia central de esta lección. En inglés.
- React, "Lifecycle of Reactive Effects" — react.dev/learn/lifecycle-of-reactive-effects. El ciclo "empezar a sincronizar / dejar de sincronizar" y por qué el cleanup corre antes de cada re-sincronización; el modelo mental del setup/cleanup. En inglés.
- React, "useEffect" (referencia) — react.dev/reference/react/useEffect. La firma de
useEffect, la función de cleanup que se devuelve, y cuándo se ejecuta. La referencia de la API. En inglés. - MDN, "EventTarget: removeEventListener()" — developer.mozilla.org/en-US/docs/Web/API/EventTarget/removeEventListener. Por qué
removeEventListenernecesita la misma referencia de función queaddEventListener; el detalle que hace que un cleanup de listener funcione. En inglés.