Busca en todas las páginas de la documentación
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥
// El patrón central: todo useEffect que asigne debe limpiar
useEffect(() => {
// ASIGNAR: suscripción, escuchador, temporizador, búsqueda
const controller = new AbortController();
const intervalId = setInterval(pollData, 5000);
window.addEventListener("resize", handleResize);
fetchData({ signal: controller.signal });
// LIMPIEZA: se ejecuta al desmontar y antes de volver a ejecutar el efecto
return () => {
controller.abort();
clearInterval(intervalId);
window.removeEventListener("resize", handleResize);
};
}, []);Cuándo usarlo: Cuando el uso de memoria de tu aplicación crece con el tiempo durante la navegación, cuando Chrome DevTools muestra un tamaño de heap de JS en aumento, o cuando los usuarios reportan que la aplicación se vuelve más lenta cuanto más la usan.
// ---- ANTES: Componente con 5 fugas de memoria ----
function LiveDashboard({ userId }: { userId: string }) {
const [data, setData] = useState<DashboardData | null>(null);
const [mousePos, setMousePos] = useState({ x: 0, y: 0 });
const [notifications, setNotifications] = useState<string[]>([]);
const chartRef = useRef<HTMLCanvasElement>(null);
// FUGA 1: Búsqueda sin abortar - actualiza el estado después del desmontaje
useEffect(() => {
async function loadData() {
const res = await fetch(`/api/dashboard/${userId}`);
const json = await res.json();
setData(json); // ¡Puede ejecutarse después del desmontaje!
}
loadData();
}, [userId]);
// FUGA 2: Intervalo nunca se limpia - sigue ejecutándose después del desmontaje
useEffect(() => {
setInterval(async () => {
const res = await fetch(`/api/dashboard/${userId}/live`);
const json = await res.json();
setData(json); // Se ejecuta para siempre, incluso después de navegar
}, 5000);
}, [userId]);
// FUGA 3: Escuchador de evento nunca se remueve
useEffect(() => {
window.addEventListener("mousemove", (e) => {
setMousePos({ x: e.clientX, y: e.clientY });
});
}, []);
// FUGA 4: WebSocket nunca se cierra
useEffect(() => {
const ws = new WebSocket(`wss://api.example.com/ws/${userId}`);
ws.onmessage = (event) => {
setNotifications((prev) => [...prev, event.data]);
};
// Sin limpieza - WebSocket permanece abierto
}, [userId]);
// FUGA 5: Contexto de canvas y datos grandes retenidos por cierre
useEffect(() => {
const ctx = chartRef.current?.getContext("2d");
const hugeDataSet = new Float64Array(1_000_000); // Asignación de 8MB
function drawChart() {
// El cierre retiene hugeDataSet incluso después del desmontaje del componente
ctx?.clearRect(0, 0, 800, 400);
// ... dibujar usando hugeDataSet
}
drawChart();
// hugeDataSet nunca se libera porque drawChart retiene una referencia
}, [data]);
return (
<div>
<canvas ref={chartRef} width={800} height={400} />
<p>Ratón: {mousePos.x}, {mousePos.y}</p>
<ul>
{notifications.map((n, i) => (
<li key={i}>{n}</li>
))}
</ul>
</div>
);
}
// ---- DESPUÉS: Las 5 fugas corregidas - limpieza adecuada en cada efecto ----
function LiveDashboard({ userId }: { userId: string }) {
const [data, setData] = useState<DashboardData | null>(null);
const [mousePos, setMousePos] = useState({ x: 0, y: 0 });
const [notifications, setNotifications] = useState<string[]>([]);
const chartRef = useRef<HTMLCanvasElement>(null);
// CORRECCIÓN 1: AbortController cancela la búsqueda al desmontar o cambiar userId
useEffect(() => {
const controller = new AbortController();
async function loadData() {
try {
const res = await fetch(`/api/dashboard/${userId}`, {
signal: controller.signal,
});
const json = await res.json();
setData(json);
} catch (err) {
if (err instanceof DOMException && err.name === "AbortError") {
// Se espera cuando el componente se desmonta - no es un error
return;
}
throw err;
}
}
loadData();
return () => controller.abort();
}, [userId]);
// CORRECCIÓN 2: Intervalo se limpia al desmontar, AbortController para cada encuesta
useEffect(() => {
const controller = new AbortController();
const intervalId = setInterval(async () => {
try {
const res = await fetch(`/api/dashboard/${userId}/live`, {
signal: controller.signal,
});
const json = await res.json();
setData(json);
} catch (err) {
if (err instanceof DOMException && err.name === "AbortError") return;
console.error("Encuesta falló:", err);
}
}, 5000);
return () => {
clearInterval(intervalId);
controller.abort();
};
}, [userId]);
// CORRECCIÓN 3: Manejador nombrado + removeEventListener en limpieza
useEffect(() => {
const handleMouseMove = (e: MouseEvent) => {
setMousePos({ x: e.clientX, y: e.clientY });
};
window.addEventListener("mousemove", handleMouseMove);
return () => window.removeEventListener("mousemove", handleMouseMove);
}, []);
// CORRECCIÓN 4: WebSocket cerrado en limpieza
useEffect(() => {
const ws = new WebSocket(`wss://api.example.com/ws/${userId}`);
ws.onmessage = (event) => {
setNotifications((prev) => [...prev, event.data]);
};
ws.onerror = (error) => {
console.error("Error de WebSocket:", error);
};
return () => {
ws.close();
};
}, [userId]);
// CORRECCIÓN 5: Limpieza anula datos grandes, usa ref para vinculación mutable
useEffect(() => {
const ctx = chartRef.current?.getContext("2d");
let hugeDataSet: Float64Array | null = new Float64Array(1_000_000);
function drawChart() {
if (!hugeDataSet || !ctx) return;
ctx.clearRect(0, 0, 800, 400);
// ... dibujar usando hugeDataSet
}
drawChart();
return () => {
hugeDataSet = null; // Libera 8MB - permite la recolección de basura
};
}, [data]);
return (
<div>
<canvas ref={chartRef} width={800} height={400} />
<p>Ratón: {mousePos.x}, {mousePos.y}</p>
<ul>
{notifications.map((n, i) => (
<li key={i}>{n}</li>
))}
</ul>
</div>
);
}Lo que esto demuestra:
clearInterval detiene las encuestas cuando el componente ya no es visibleremoveEventListeneruseEffect se ejecuta en dos escenarios: (1) antes de que el efecto se vuelva a ejecutar debido a cambios de dependencia, y (2) cuando el componente se desmonta. Aquí es donde debe ocurrir toda la desasignación de recursos.setState en un componente desmontado es una no operación en React 18+, pero el cierre en sí mismo impide la recolección de basura de todo lo que referencia.useEffect sin limpieza significa que cada remontaje (navegación) agrega otro escuchador. Después de 10 navegaciones, tienes 10 escuchadores todos disparándose en cada evento.AbortController para múltiples búsquedas concurrentes:
useEffect(() => {
const controller = new AbortController();
async function loadAll() {
const [users, orders, metrics] = await Promise.all([
fetch("/api/users", { signal: controller.signal }),
fetch("/api/orders", { signal: controller.signal }),
fetch("/api/metrics", { signal: controller.signal }),
]);
// Un aborto cancela las tres
setData({
users: await users.json(),
orders: await orders.json(),
metrics: await metrics.json(),
});
}
loadAll().catch((err) => {
if (err.name !== "AbortError") throw err;
});
return () => controller.abort();
}, []);WeakRef para referencias opcionales:
// Avanzado: WeakRef permite la recolección de basura del objeto referenciado
function useWeakCallback<T extends object>(target: T, callback: (target: T) => void) {
const weakRef = useRef(new WeakRef(target));
useEffect(() => {
const intervalId = setInterval(() => {
const obj = weakRef.current.deref();
if (obj) {
callback(obj);
} else {
// El objeto fue recolectado como basura - detener la encuesta
clearInterval(intervalId);
}
}, 1000);
return () => clearInterval(intervalId);
}, [callback]);
}Detectar fugas con snapshots de heap:
1. Abre Chrome DevTools -> Pestaña Memory
2. Toma Heap Snapshot (Snapshot 1 - línea base)
3. Navega a la página sospechosa de fugas
4. Navega alejándote de la página
5. Haz clic en el botón de recolección de basura (icono de papelera)
6. Toma Heap Snapshot (Snapshot 2)
7. Selecciona Snapshot 2 -> Cambia la vista a "Comparación"
8. Ordena por la columna "Delta" - los objetos con deltas positivos son posibles fugas
9. Busca: HTMLDivElement desconectado, EventListener, Cierre, Array
AbortController y AbortSignal están incorporados en la librería DOM. Sin tipos adicionales necesarios.void o undefined (useEffect prohíbe devolver cualquier otra cosa).WeakRef<T> requiere T extends object. Los primitivos no pueden ser débilmente referenciados.FinalizationRegistry<T> recibe el valor retenido de tipo T cuando el destino es recolectado.Las funciones anónimas previenen la limpieza - window.addEventListener("resize", () => {...}) no se puede remover porque removeEventListener requiere la misma referencia de función. Corrección: Siempre usa funciones nombradas o almacena la referencia en una variable.
El desmontaje doble de Strict Mode enmascara fugas - React 18+ Strict Mode monta, desmonta y remonta cada componente en desarrollo. Esto puede hacer que las fugas aparezcan como comportamiento "normal". Corrección: Un componente adecuadamente limpiado funciona correctamente a través del ciclo de montaje de Strict Mode. Si ves escuchadores o conexiones duplicadas, tu limpieza es incompleta.
Falta de dependencia en el efecto causa limpieza obsoleta - Si la función de limpieza referencia una variable que no está en el array de dependencias, puede cerrar sobre un valor obsoleto. Corrección: Incluye todas las variables referenciadas en el array de dependencias, o usa refs para valores mutables que no deberían provocar re-ejecuciones.
Limpieza de biblioteca de terceros - Bibliotecas como chart.js, mapbox-gl o reproductores de video asignan recursos internos. Corrección: Llama al método destroy/dispose de la biblioteca en la función de limpieza: chart.destroy(), map.remove(), player.dispose().
React 18+ ya no advierte sobre actualizaciones de estado en componentes desmontados - La advertencia se eliminó porque produjo demasiados falsos positivos. Pero la fuga subyacente aún existe. Corrección: Usa AbortController y funciones de limpieza independientemente de si React te advierte.
Temporizadores con cierre sobre arrays crecientes - Las devoluciones de llamada de setInterval que se añaden a un array crean una estructura de datos cada vez más grande incluso si el componente se remonta con un estado nuevo. Corrección: Limpia el intervalo en la limpieza y asegúrate de que el cierre no referencia acumuladores de montajes anteriores.
| Enfoque | Compensación |
|---|---|
useEffect limpieza | Incorporado; requiere disciplina manual para cada efecto |
| AbortController | API estándar; solo funciona con búsqueda y APIs que aceptan AbortSignal |
| TanStack Query | Gestión automática del ciclo de vida de búsqueda; añade dependencia |
| SWR | Deduplicación y limpieza automática de solicitudes; añade dependencia |
Hook use (React 19) | Basado en Promise; funciona con Suspense, sin limpieza manual necesaria |
| RxJS | Suscripciones observables con limpieza automática; dependencia pesada |
| Suscripciones de Zustand | Gestión de tienda; limpieza vía retorno de subscribe |
Todo useEffect que asigne recursos debe devolver una función de limpieza:
useEffect(() => {
const controller = new AbortController();
fetchData({ signal: controller.signal });
return () => controller.abort();
}, []);La limpieza se ejecuta al desmontar y antes de que el efecto se vuelva a ejecutar.
La advertencia se eliminó porque produjo demasiados falsos positivos. Sin embargo, la fuga subyacente aún existe -- el cierre retiene referencias al estado del componente e impide la recolección de basura. Usa AbortController y funciones de limpieza independientemente.
Usa AbortController y pasa su señal a fetch:
useEffect(() => {
const controller = new AbortController();
fetch(url, { signal: controller.signal })
.then((r) => r.json())
.then(setData)
.catch((err) => {
if (err.name === "AbortError") return;
throw err;
});
return () => controller.abort();
}, [url]);window.addEventListener("resize", () => {...}) no se puede remover porque removeEventListener requiere la exacta misma referencia de función.
Corrección: Almacena el manejador en una variable:
const handleResize = () => { /* ... */ };
window.addEventListener("resize", handleResize);
return () => window.removeEventListener("resize", handleResize);React 18+ Strict Mode monta, desmonta y remonta cada componente en desarrollo. Esto puede hacer que las fugas aparezcan como comportamiento "normal". Un componente adecuadamente limpiado funciona correctamente a través de este ciclo -- si ves escuchadores o conexiones duplicadas, tu limpieza es incompleta.
Anula datos grandes en la función de limpieza para permitir la recolección de basura:
useEffect(() => {
let hugeData: Float64Array | null = new Float64Array(1_000_000);
drawChart(hugeData);
return () => { hugeData = null; };
}, [data]);Cierra el WebSocket en la función de limpieza:
useEffect(() => {
const ws = new WebSocket(`wss://api.example.com/ws/${id}`);
ws.onmessage = (event) => setData(event.data);
return () => ws.close();
}, [id]);WeakRef<T> requiere T extends object. Los primitivos (strings, números, booleanos) no pueden ser débilmente referenciados. El tipo de devolución de la función de limpieza desde useEffect debe ser void o undefined.
Llama al método destroy/dispose de la biblioteca en la función de limpieza:
useEffect(() => {
const chart = new Chart(canvasRef.current, config);
return () => chart.destroy();
}, [config]);Las bibliotecas que manipulan el DOM directamente retienen nodos desconectados si no se destruyen adecuadamente.
Sí. Pasa la misma signal a todas las llamadas de búsqueda. Llamar a controller.abort() una vez cancela cada solicitud que usa esa señal:
const [users, orders] = await Promise.all([
fetch("/api/users", { signal: controller.signal }),
fetch("/api/orders", { signal: controller.signal }),
]);La devolución de llamada continúa añadiendo al array incluso si el componente se remonta con estado nuevo. El cierre antiguo retiene el array antiguo, creando una estructura de datos cada vez más grande. Siempre limpia el intervalo en la limpieza.
Revisado por Chris St. John·Última actualización: 10 jul 2026
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥