Cleanup, cancelación y race conditions en React | Nicolás Garzón
Un effect debe poder iniciar y detener la sincronización que controla.
TypeScript
Copiar useEffect ( ( ) => {
window. addEventListener ( "resize" , handleResize) ;
return ( ) => window. removeEventListener ( "resize" , handleResize) ;
} , [ ] ) ; La limpieza ocurre antes de volver a ejecutar el effect y al desmontar.
TypeScript
Copiar useEffect ( ( ) => {
const controller = new AbortController ( ) ;
async function load ( ) {
const response = await fetch ( ` /api/users/ ${ userId} ` , {
signal: controller. signal,
} ) ;
const user = await response. json ( ) ;
setUser ( user) ;
}
load ( ) . catch ( ( error) => {
if ( error. name !== "AbortError" ) setError ( error) ;
} ) ;
return ( ) => controller. abort ( ) ;
} , [ userId] ) ; Si userId cambia rápido, una respuesta antigua puede llegar después de una nueva y sobrescribirla. Cancelar o ignorar respuestas obsoletas evita ese problema.
En desarrollo React ejecuta setup, cleanup y setup adicionalmente para detectar efectos que no son reversibles.
Cleanup vacío que no cancela nada.
Actualizar estado después de una respuesta obsoleta.
Compartir un controller entre ejecuciones.
Confundir cancelación con manejo de caché.
Cada ejecución de effect representa una sincronización concreta y debe poder invalidarse.
Un effect no tiene un único setup global. Cada combinación de dependencias crea una sincronización concreta:
Texto
Copiar roomId = general → setup A
roomId cambia
→ cleanup A
roomId = support → setup B
unmount → cleanup BEl cleanup debe deshacer exactamente el trabajo del setup correspondiente.
TypeScript
Copiar useEffect ( ( ) => {
function handleResize ( ) {
setWidth ( window. innerWidth) ;
}
window. addEventListener ( "resize" , handleResize) ;
return ( ) => window. removeEventListener ( "resize" , handleResize) ;
} , [ ] ) ; La misma referencia handleResize se usa para registrar y retirar. Crear otra función en cleanup no elimina el listener anterior.
TypeScript
Copiar useEffect ( ( ) => {
const id = window. setInterval ( refresh, intervalMs) ;
return ( ) => window. clearInterval ( id) ;
} , [ intervalMs] ) ; Cuando cambia el intervalo, React cancela el timer anterior antes de establecer el siguiente.
No toda operación puede abortarse. Dos estrategias:
AbortController detiene APIs compatibles, como fetch.
TypeScript
Copiar useEffect ( ( ) => {
let ignore = false ;
async function load ( ) {
const result = await legacyRequest ( userId) ;
if ( ! ignore) setUser ( result) ;
}
void load ( ) ;
return ( ) => {
ignore = true ;
} ;
} , [ userId] ) ; La operación puede continuar, pero no actualiza la instancia invalidada.
Se solicita usuario A.
Cambia userId y se solicita B.
B responde primero y se muestra.
A responde después.
Sin invalidación, A sobrescribe a B.
El orden de respuesta no tiene por qué coincidir con el de solicitud. El cleanup marca cuál ejecución sigue siendo válida.
Una request puede terminar justo antes de llamar abort, o puede existir transformación asíncrona posterior. Comprueba el signal o la vigencia antes de aplicar resultados.
TypeScript
Copiar if ( controller. signal. aborted) return ;
setState ( { status: "success" , data } ) ; TypeScript
Copiar useEffect ( ( ) => {
const unsubscribe = store. subscribe ( handleChange) ;
return unsubscribe;
} , [ store] ) ; El contrato de subscribe debe devolver una limpieza idempotente. Para stores externas compartidas, useSyncExternalStore ofrece garantías adicionales sobre snapshots y rendering concurrente.
Antes de volver a correr el effect por dependencias.
Al desmontar.
Durante pruebas adicionales de Strict Mode en desarrollo.
Por eso mensajes como “component unmounted” pueden ser imprecisos dentro del cleanup; también puede tratarse de una re-sincronización.
Texto
Copiar setup → cleanup → setupSi aparecen listeners duplicados, conexiones abiertas o timers múltiples, el setup no era reversible. No introduzcas una ref para impedir la segunda ejecución; corrige cleanup y diseño.
Algunas mutaciones pueden repetirse por reintentos, navegación o doble activación. La idempotencia pertenece al servidor o protocolo, no solo al effect:
IDs de operación.
Claves de idempotencia.
Constraints de base de datos.
Estados transaccionales.
No inicies compras o eliminaciones críticas automáticamente desde effects si corresponden a una acción del usuario.
Un abort esperado no debería mostrarse como fallo:
TypeScript
Copiar catch ( error) {
if ( controller. signal. aborted) return ;
setError ( toMessage ( error) ) ;
} No dependas únicamente de error.name, porque distintas APIs pueden representar cancelación de maneras diferentes.
Cancelar requests obsoletas no resuelve:
Reutilizar datos.
Evitar requests duplicados entre componentes.
Revalidar.
Prefetch.
Mutaciones.
Para aplicaciones reales, una capa de server state o el framework puede ser mejor que effects manuales.
Cuando aparecen datos equivocados:
Registra un identificador por ejecución.
Comprueba orden de inicio y respuesta.
Revisa si cleanup invalida la anterior.
Confirma que el resultado comprueba vigencia.
Revisa si otro componente solicita el mismo recurso.
Evalúa una caché especializada.
Cada setup necesita una limpieza simétrica.
Cleanup ocurre también al cambiar dependencias.
Cancelar trabajo e ignorar resultados son estrategias distintas.
Una respuesta antigua puede llegar después de una nueva.
Strict Mode revela sincronizaciones no reversibles.
¿Por qué una función nueva en removeEventListener no limpia el listener?
¿Cuándo necesitas ignorar en vez de cancelar?
¿Por qué cleanup no implica siempre unmount?
¿Qué no resuelve AbortController?
Ver respuestas
Porque el navegador elimina por la misma referencia registrada.
Cuando la API no soporta cancelación o existe trabajo posterior.
Porque también precede a un setup nuevo por cambio de dependencias.
Caché, deduplicación, invalidación y estrategia completa de datos.
Cuándo no necesitas un Effect elimina sincronizaciones que en realidad son cálculos o eventos.