En el modelo declarativo de React 19, un componente se diseña como una función pura que transforma props y estado en una representación visual. Sin embargo, al interactuar con el entorno imperativo del navegador (temporizadores setInterval, sockets persistentes, suscripciones a eventos de window, cadenas de promesas asíncronas), surgen defectos latentes capaces de degradar o cerrar la pestaña del cliente.
En aplicaciones SPA corporativas con sesiones prolongadas (paneles de control, terminales financieras, plataformas de analítica), una gestión descuidada de los efectos secundarios provoca tres anomalías críticas:
Para resolver estas vulnerabilidades en React 19 se requiere una estricta disciplina de limpieza (Cleanup Phase), el uso sistemático de AbortController y el análisis de la memoria con capturas de Heap (Heap Snapshot).
Anatomía típica de una condición de carrera y fuga de recursos en efectos:
Render #1 (Petición A, ID=10) ──────────► [Petición de red A (lenta, 1200 ms)]
│ │
▼ ▼
El usuario hace clic en Tab 2 │
│ │
▼ │
Render #2 (Petición B, ID=11) ──► [Petición B (rápida, 150 ms)]
│ │
▼ │
UI actualizada con datos B │
▼
¡Petición A termina al final!
stateUpdate(A) sobrescribe la UI!
──► [BUG: La UI muestra datos obsoletos del ID=10]Si el componente se desmonta antes de que la Petición A responda:
[Componente Desmontado] ◄── [Clausura dentro de Promise.then()] ◄── [Macrotask]
│
└── ¡No puede ser liberado por el Garbage Collector (Detached DOM Element)!Abre la pestaña Memory en Chrome DevTools, fuerza la recolección de basura (icono de papelera) y toma el Heap snapshot 1.
Abre el modal o vista bajo sospecha, realiza las interacciones principales y ciérralo por completo de 5 a 10 veces.
Vuelve a forzar la recolección de basura para limpiar memoria transitoria y genera el Heap snapshot 2.
Selecciona el filtro 'Objects allocated between Snapshot 1 and 2'. Filtra por nombre del componente o Detached HTMLElement.
En el panel inferior Retainers localiza la raíz de la fuga: un listener de window huérfano, un setInterval o una clausura de promesa.
Considera este código de botón: const [count, setCount] = useState(0); const inc = useCallback(() => { setCount(count + 1); }, []);. ¿Por qué al hacer clic repetidamente el valor del contador no pasa de 1?
AbortController, los gráficos muestran datos desordenados y desincronizados.setInterval acumula procesos en segundo plano hasta agotar la CPU.Instanciación de un nuevo objeto AbortController() y extracción de su AbortSignal.
Envío de la petición HTTP asociando la señal de cancelación al socket del navegador.
El usuario cambia de entidad o navega a otra ruta. React invoca la función de retorno de cleanup.
El navegador interrumpe la conexión (DOMException: AbortError), liberando la memoria en V8.
React exige que el cuerpo del componente sea idempotente (una función pura). Cualquier mutación directa sobre las props o estructuras globales durante el cálculo del JSX rompe el algoritmo de reconciliación:
// ❌ Antipatrón severo: mutar las props directamente durante el cálculo del JSX
export function UserList({ users }: { users: string[] }) {
// sort() muta el array original in-place, alterando referencias y rompiendo la memoización del padre
const sortedUsers = users.sort();
return (
<ul>
{sortedUsers.map((u) => (
<li key={u}>{u}</li>
))}
</ul>
);
}
// ✅ Solución correcta: copia inmutable o useMemo
export function UserListFixed({ users }: { users: string[] }) {
const sortedUsers = useMemo(() => {
return [...users].sort();
}, [users]);
return (
<ul>
{sortedUsers.map((u) => (
<li key={u}>{u}</li>
))}
</ul>
);
}Bajo <React.StrictMode>, React invoca los renders dos veces (Double Render) en desarrollo para poner en evidencia mutaciones indebidas y efectos secundarios.
¿Por qué el patrón con la bandera isMounted (let isMounted = true; ... if (isMounted) setState(...); return () => { isMounted = false; }) se considera una mala práctica en comparación con AbortController?
A continuación se muestra el hook de producción useSafeAsync, con tipado estricto en TypeScript, cancelación garantizada mediante AbortController, protección contra condiciones de carrera y limpieza determinista:
import { useState, useEffect, useRef, type DependencyList } from 'react';
export interface AsyncState<T> {
data: T | null;
error: Error | null;
isLoading: boolean;
}
export type AsyncFn<T> = (signal: AbortSignal) => Promise<T>;
export function useSafeAsync<T>(
asyncFn: AsyncFn<T>,
deps: DependencyList
): AsyncState<T> {
const [state, setState] = useState<AsyncState<T>>({
data: null,
error: null,
isLoading: true,
});
// Contador de peticiones para garantizar inmunidad frente a condiciones de carrera
const requestCounterRef = useRef<number>(0);
useEffect(() => {
const currentRequestId = ++requestCounterRef.current;
const controller = new AbortController();
// Establecer estado de carga
setState((prev) => ({ ...prev, isLoading: true, error: null }));
async function execute() {
try {
const result = await asyncFn(controller.signal);
// Verificar que la respuesta corresponda a la petición más reciente
if (currentRequestId === requestCounterRef.current) {
setState({
data: result,
error: null,
isLoading: false,
});
}
} catch (err: unknown) {
// Ignorar el error estándar de cancelación (AbortError)
if (err instanceof Error && err.name === 'AbortError') {
return;
}
if (currentRequestId === requestCounterRef.current) {
const formattedError =
err instanceof Error ? err : new Error('Error desconocido en la carga de datos');
setState({
data: null,
error: formattedError,
isLoading: false,
});
}
}
}
void execute();
// Fase de limpieza: abortar la petición activa al desmontar o cambiar dependencias
return () => {
controller.abort();
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, deps);
return state;
}Suscribirse a eventos de ventana (window.addEventListener('resize', ...)) o iniciar intervalos sin retornar la función de limpieza acumula manejadores y provoca fugas de memoria.
// ❌ Antipatrón: en cada montaje se crea un listener que nunca se elimina
useEffect(() => {
window.addEventListener('resize', handleResize);
}, []); // ¡Fuga de memoria al desmontar el componente!
// ✅ Solución correcta: limpieza explícita en return o uso de AbortSignal
useEffect(() => {
const controller = new AbortController();
// ¡Los navegadores admiten la opción signal en addEventListener!
window.addEventListener('resize', handleResize, { signal: controller.signal });
return () => {
controller.abort(); // Elimina automáticamente el listener de la memoria
};
}, []);Filtros tipo if (isMounted.current) setState(...) no detienen la promesa ni la conexión de red en segundo plano, consumiendo ancho de banda y memoria.
// ❌ Antipatrón: el parche con isMounted maquilla la fuga de recursos
useEffect(() => {
let isMounted = true;
fetchData().then((data) => {
if (isMounted) setState(data); // ¡La respuesta se descarga entera de todas formas!
});
return () => {
isMounted = false;
};
}, []);
// ✅ Solución correcta: cancelación física mediante AbortController
useEffect(() => {
const controller = new AbortController();
fetchData({ signal: controller.signal })
.then((data) => setState(data))
.catch((err: unknown) => {
if (err instanceof Error && err.name === 'AbortError') return;
console.error(err);
});
return () => controller.abort();
}, []);Definir objetos de configuración dentro del cuerpo del componente y pasarlos al array de dependencias desencadena un bucle infinito de renders.
// ❌ Antipatrón: options se recrea en cada render -> bucle infinito
function Widget({ query }: { query: string }) {
const options = { filter: query, page: 1 }; // ¡Nueva referencia en memoria cada vez!
useEffect(() => {
fetchItems(options);
}, [options]); // ¡Bucle infinito!
}
// ✅ Solución correcta: dependencias primitivas o useMemo estable
function WidgetFixed({ query }: { query: string }) {
useEffect(() => {
fetchItems({ filter: query, page: 1 });
}, [query]); // query es un string primitivo estable
}