Detectar y Corregir Fugas de Memoria — Encuentra y elimina las fugas de memoria que degradan el rendimiento con el tiempo
Receta
// 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.
Ejemplo de Trabajo
// ---- 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:
- AbortController cancela las búsquedas en vuelo al desmontar, evitando actualizaciones de estado en componentes desmontados
clearIntervaldetiene las encuestas cuando el componente ya no es visible- Las funciones de manejador de eventos nombradas permiten la limpieza adecuada de
removeEventListener - Las conexiones de WebSocket se cierran explícitamente
- Las asignaciones de datos grandes se anulan en la limpieza para permitir la recolección de basura
- Después de las correcciones: el uso de heap disminuye en ~50MB después de navegar desde el panel 10 veces
Análisis Profundo
Cómo Funciona
- Función de limpieza de useEffect — La función devuelta desde
useEffectse 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. - Fugas de cierre obsoleto — Cuando una operación asincrónica (búsqueda, tiempo de espera, mensaje de WebSocket) se completa después del desmontaje, la devolución de llamada aún mantiene una referencia al setter de estado del componente mediante cierre. Llamar a
setStateen 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. - Nodos DOM desconectados — Si se almacena una referencia a un nodo DOM en un cierre o variable externa y el nodo se elimina del DOM, el nodo y todo su subárbol permanecen en memoria. Esto es común con bibliotecas de gráficos y código que manipula el DOM de terceros.
- Acumulación de escuchadores de eventos — Agregar escuchadores de eventos en
useEffectsin limpieza significa que cada remontaje (navegación) agrega otro escuchador. Después de 10 navegaciones, tienes 10 escuchadores todos disparándose en cada evento. - Pestaña de Memoria de Chrome DevTools — Toma un snapshot de heap antes y después de navegar hacia/desde un componente. Compara snapshots para encontrar objetos que deberían haber sido recolectados como basura. Filtra por "Desconectado" para encontrar nodos DOM desconectados.
Variaciones
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
Notas de TypeScript
- Los tipos de
AbortControlleryAbortSignalestán incorporados en la librería DOM. Sin tipos adicionales necesarios. - Escribe la devolución de la función de limpieza como
voidoundefined(useEffect prohíbe devolver cualquier otra cosa). WeakRef<T>requiereT extends object. Los primitivos no pueden ser débilmente referenciados.- La devolución de llamada de
FinalizationRegistry<T>recibe el valor retenido de tipoTcuando el destino es recolectado.
Trampas
-
Las funciones anónimas previenen la limpieza —
window.addEventListener("resize", () => {...})no se puede remover porqueremoveEventListenerrequiere 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
setIntervalque 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.
Alternativas
| 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 |
Preguntas Frecuentes
¿Cuál es el patrón más común para prevenir fugas de memoria en useEffect?
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.
¿Por qué React 18+ ya no advierte sobre actualizaciones de estado en componentes desmontados?
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.
¿Cómo cancelas una solicitud de búsqueda en vuelo cuando un componente se desmonta?
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]);Trampa: ¿Por qué no puedes remover un escuchador de evento anónimo?
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);Trampa: ¿Cómo afecta el desmontaje doble de Strict Mode a la detección de fugas de memoria?
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.
¿Cómo detectas fugas de memoria usando snapshots de heap de Chrome DevTools?
- Toma un snapshot de heap (línea base)
- Navega a la página sospechosa de fugas, luego navega alejándote
- Haz clic en el botón de recolección de basura
- Toma un segundo snapshot y selecciona la vista "Comparación"
- Ordena por la columna "Delta" -- los objetos con deltas positivos son posibles fugas
- Busca: HTMLDivElement desconectado, EventListener, Cierre, Array
¿Cómo deberías manejar asignaciones de datos grandes en useEffect para prevenir fugas?
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]);¿Cuál es la limpieza correcta para conexiones de WebSocket?
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]);¿Qué restricción de TypeScript se aplica a WeakRef?
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.
¿Cómo manejas la limpieza para bibliotecas de terceros como chart.js o mapbox-gl?
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.
¿Puede un único AbortController cancelar múltiples solicitudes de búsqueda concurrentes?
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 }),
]);¿Qué sucede con las devoluciones de llamada de setInterval que referencian arrays crecientes después del desmontaje?
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.
Relacionado
- Análisis de Rendimiento — Pestaña de Memoria de Chrome DevTools para detectar fugas de memoria
- Rendimiento de Búsqueda de Datos — Gestión del ciclo de vida de búsqueda con TanStack Query y SWR
- Suspense y Streaming — La búsqueda de datos del lado del servidor elimina la limpieza de búsqueda del lado del cliente
- Lista de Verificación de Rendimiento — Auditoría de fugas de memoria como parte del proceso de revisión de rendimiento