Por qué las funciones de callback de React useEffect nunca deben ser funciones asíncronas
Una función asíncrona pasada a useEffect viola el contrato de limpieza de React porque devuelve una Promise. Cuatro patrones correctos para trabajar de forma asíncrona dentro de los efectos.
Esta pregunta surgió durante una entrevista de frontend en Paytm, y merece más reconocimiento del que sugiere su simplicidad. A primera vista parece ser un pequeño problema de sintaxis. En realidad, evalúa si comprendes el ciclo de vida de los efectos, cómo se comportan realmente las Promises en JavaScript, qué hace React internamente con lo que devuelva tu efecto, y cómo obtener datos de la manera correcta dentro de un componente.
La mayoría de los candidatos ofrecen una respuesta técnicamente correcta, pero que solo aborda lo superficial:
“useEffect espera que devuelvas una función de limpieza, mientras que una función asíncrona devuelve una Promise.”
Esa es una frase de apertura razonable, pero deja sin respuesta las preguntas interesantes. ¿Qué hace React con esa Promise una vez que la recibe? ¿La descarta simplemente? ¿Qué es lo que realmente falla como resultado? Y, más útil aún, ¿cómo deberías estructurar la lógica asíncrona dentro de un effect para que nada falle?
Vamos a construir la imagen completa poco a poco, verificando cada afirmación contra la documentación de React y el propio código fuente de React.
Parte 1: El contrato que espera useEffect
La función pasada a useEffect se denomina oficialmente función de configuración, y la documentación de React es estricta respecto a lo que permite devolver. Solo hay dos resultados aceptables:
- Devolver nada, es decir, que el valor sea
undefined, o - Devolver una función de limpieza que React invocará en un momento posterior.
useEffect(() => {
// setup: runs after the component is committed to the screen
const id = setInterval(tick, 1000);
// cleanup: React calls this before the next setup run,
// and one final time when the component unmounts
return () => clearInterval(id);
}, []);
Esa función de limpieza no es algo opcional. Es el mecanismo del que depende React para desmontar todo lo que se creó durante la fase de configuración, ya sea un temporizador, una suscripción, un oyente de eventos o una conexión WebSocket abierta. Según la documentación de React, este libera tu función de limpieza justo antes de volver a ejecutar el efecto, y nuevamente cuando el componente se desmonta definitivamente.
Aquí está el detalle que hace importante toda esta cuestión: React necesita esa función de limpieza de inmediato, en el instante en que termina de ejecutarse tu función de configuración. A veces la limpieza debe realizarse casi al instante, posiblemente incluso antes de que se resuelva el primer await dentro de una función asíncrona. Una función de limpieza que aparece “eventualmente” es una que React no puede utilizar.
Parte 2: Por qué async siempre significa Promise
Esta sección corresponde al comportamiento habitual de JavaScript y no presenta excepciones. Según MDN, las funciones declaradas con async siempre devuelven un Promise, punto. Incluso si se devuelve algo que no es ya un Promise, JavaScript lo envuelve silenciosamente en uno por usted. No existe ninguna sintaxis que permita eludir esto.
async function getCleanup() {
return () => console.log('cleaning up');
}
const result = getCleanup();console.log(result);
// Promise { <fulfilled>: f } <- the function is trapped insideconsole.log(typeof result);
// "object" <- not "function"
Observe con atención lo que sucedió allí. El código devuelve explícitamente una función flecha, y sin embargo quien la llama no recibe una función. En su lugar, obtiene un Promise que eventualmente se resolverá en esa función. Esto también es cierto para una función async sin ninguna instrucción de retorno: sigue devolviendo un Promise, este que se resolverá en undefined.
Combine ambos aspectos:
- React espera ya sea
undefinedo una función real, entregada de forma síncrona.
Por lo tanto, el contrato ya se viola antes de que tenga oportunidad de ejecutarse cualquier lógica de efectos.
// What you think you wrote:
useEffect(async () => {
const data = await fetchUser(id);
setUser(data);
return () => controller.abort(); // "my cleanup"
}, [id]);
// What React receives from your setup function:
// Promise { <pending> }
// Your cleanup is locked inside a box React never opens.
Parte 3: Qué hace realmente React con el valor devuelto
Aquí es donde muchas explicaciones se desvían. Se suele decir que “React simplemente ignora la Promise que devuelves”. El código fuente cuenta una historia diferente. React en realidad realiza tres acciones separadas con ese valor devuelto, y dos de ellas causan problemas reales.
3.1 Almacena la Promise como operación de limpieza
Dentro del reconciler, más concretamente en commitHookEffectListMount —la función encargada de ejecutar tus efectos después de un commit—, React llama a tu función setup y almacena directamente su valor de retorno, sin hacer preguntas. En una compilación para producción no existe ninguna verificación en tiempo de ejecución para detectar tipos incompatibles:
// react-reconciler/src/ReactFiberCommitEffects.js (simplified)
const create = effect.create; // your setup function
const destroy = create(); // whatever it returns...
inst.destroy = destroy; // ...is stored as the cleanup
Si la función setup que pasaste era asíncrona, inst.destroy ahora contiene una Promise pendiente en el lugar exacto donde React espera una función de limpieza llamable.
3.2 La compilación para desarrollo muestra una advertencia muy específica
Una vez que el valor se almacena, las compilaciones de desarrollo ejecutan un paso adicional de validación. React verifica si typeof destroy !== 'function'; además de realizar una comprobación genérica de tipo, busca específicamente un método .then en el valor devuelto. Eso es una señal clara de que se le ha pasado un Promise en lugar de una función, y React lo indica directamente:
useEffect must not return anything besides a function, which is used for
clean-up.
It looks like you wrote useEffect(async () => ...) or returned a Promise.
Instead, write the async function inside your effect and call it immediately:useEffect(() => {
async function fetchData() {
// You can await here
const response = await MyAPI.getData(someId);
// ...
}
fetchData();
}, [someId]); // Or [] if effect doesn't need props or stateLearn more about data fetching with Hooks: https://react.dev/link/hooks-data-fetching
Obsérvese que la advertencia no solo se limita a señalar el problema, sino que también incluye la solución dentro del propio mensaje, mostrando el patrón para declarar una función asíncrona dentro del efecto y llamarla de inmediato. Cuando el texto de error del propio framework ya contiene la solución, suele ser una señal clara de que los entrevistadores esperan que uno ya lo haya visto antes.
3.3 Cuando llega el momento de la limpieza, React intenta invocar el Promise
Aquí es donde las cosas se vuelven realmente peligrosas. Cuando cambian las dependencias del efecto o el componente se desmonta, la lógica de desmontaje de React solo verifica si algo fue almacenado como tarea de limpieza; nunca comprueba que el valor almacenado sea ejecutable:
// commitHookEffectListUnmount (simplified)
const destroy = inst.destroy;
if (destroy !== undefined) {
safelyCallDestroy(effect, ...); // executes destroy()
}
// destroy is a Promise here, and a Promise is not callable:
// TypeError: destroy is not a function
Intentar llamar a una Promise como si fuera una función genera un TypeError. Esto es exactamente lo que causó el conocido error “Uncaught TypeError: destroy is not a function” en React 17. En las versiones actuales de React, esta llamada está envuelta en un camino protegido, por lo que en lugar de hacer colapsar toda la página, el error se captura como un error en la fase de confirmación y se envía al límite de errores más cercano. De cualquier manera, se trata de un fallo real en tiempo de ejecución, no solo de un mensaje en la consola que se puede ignorar.
En conjunto, se obtienen tres fallos distintos:
- El código de limpieza real nunca se ejecuta, por lo que los temporizadores, los listeners de eventos y las suscripciones nunca se desactivan.
- React muestra una advertencia mientras desarrollas.
- Más tarde, cuando React intenta ejecutar la función de “limpieza”, trata de llamar a una Promise como si fuera una función y lanza un error.
Una función de configuración asíncrona no pasa desapercibida entre las reglas; las viola en dos momentos distintos del ciclo de vida del componente.
Parte 4: Comparación paso a paso de las dos rutas de ejecución
Es útil comparar una función de configuración síncrona con una asíncrona, momento a momento. Los tres primeros pasos son idénticos. Todo difiere a partir del paso cuatro.
Versión síncrona:
- Se ejecuta la función del componente y se calcula el JSX.
- React aplica la actualización y el navegador dibuja la interfaz.
- React llama a tu función de configuración.
cleanup() almacenada.Versión asíncrona:
- Se ejecuta la función del componente y se calcula el JSX.
- React realiza la actualización, y el navegador dibuja la interfaz.
- React invoca tu función de configuración, la cual —al ser asíncrona— devuelve un resultado de inmediato.
- Lo que se devuelve es un
Promise { <pending> }, y React almacena ese Promise como si fuera la función de limpieza. En el entorno de desarrollo, aquí se genera la advertencia. - Tus llamadas a
awaitsiguen resolviéndose en segundo plano, y cualquier lógica de limpieza que hayas escrito está contenida dentro de esa Promise no resuelta, por lo que es inaccesible. - Cuando las dependencias cambian o el componente se desmonta, React intenta llamar al valor almacenado —que no es una función— y lanza un
TypeError. - Nunca se ejecutó ninguna operación de limpieza. Los intervalos siguen activándose, y las respuestas obsoletas aún pueden provocar la llamada a
setState.
Esta comparación también aclara por qué el contrato exige algo síncrono. Los efectos se activan después del commit, una vez que ya ha tenido lugar la actualización de la interfaz, y la limpieza del efecto anterior debe ejecutarse justo antes de que se active el siguiente efecto, lo cual puede ocurrir tan pronto como la siguiente renderización. Si React tuviera que esperar a una Promise solo para determinar si existe una función de limpieza, toda la secuencia de commits tendría que detenerse debido al código del usuario, lo cual entra en conflicto con la forma en que está diseñada la fase de commits. Esa es la verdadera razón por la cual la API se construyó en torno a devoluciones síncronas en lugar de enseñar a React a esperar sus Promises.
Parte 5: La condición de carrera que este patrón oculta
Existe un segundo peligro, más sutil, en el patrón asíncrono: sin una función de limpieza, se pierde cualquier posibilidad de cancelar o ignorar el trabajo asíncrono obsoleto. La propia documentación de React ilustra esto con un escenario de página de perfil, y vale la pena comprenderlo bien, porque no se trata de un detalle estilístico, sino de un error que podría enviarse a producción.
Así es como ocurre:
- Haces clic en “Bob” y la aplicación comienza a obtener su biografía.
- Antes de que termine, haces clic en “Taylor”, lo que desencadena la obtención de su biografía.
- La respuesta de Taylor llega primero, por lo que la interfaz muestra su biografía.
- La respuesta de Bob, que fue más lenta, llega después y sobrescribe el estado.
- La pantalla termina mostrando a Taylor como el usuario seleccionado, pero con la biografía de Bob debajo.
Dado que no se puede garantizar que las respuestas de la red se resuelvan en el orden en que se enviaron las solicitudes, este tipo de desajuste representa un riesgo real. La solución estándar es una función de limpieza que establece una bandera ignore, lo que permite descartar todas las respuestas excepto la relacionada con la última renderización. Esa solución solo funciona si la función de configuración devuelve de forma síncrona, brindando a React una verdadera función de callback de limpieza para ejecutar.
Por lo tanto, el callback asíncrono no es simplemente una violación técnica del contrato de la API: elimina precisamente el mecanismo que impide que esta condición de carrera llegue hasta los usuarios.
Parte 6: Los patrones correctos
Toda solución válida se basa en la misma idea: mantener la función de configuración síncrona y trasladar el trabajo asíncrono a algo que esa función llame. De ese modo, React sigue recibiendo una verdadera función de callback de limpieza.
Patrón 1: Una función asíncrona con nombre dentro del efecto (la recomendación oficial)
Este es el enfoque mostrado en la documentación de React, e incluye la bandera ignore que neutraliza la condición de carrera descrita anteriormente:
useEffect(() => {
let ignore = false;
async function startFetching() {
const json = await fetchTodos(userId);
if (!ignore) {
setTodos(json);
}
} startFetching(); return () => {
ignore = true;
};
}, [userId]);
La función flecha externa nunca utiliza await, por lo que return () => { ignore = true; } proporciona a React una función de limpieza real a la que agarrarse. Mientras tanto, la función con nombre interna puede esperar lo que necesite sin restricciones.
Patrón 2: Un IIFE asíncrono
Hace lo mismo, pero con una función anónima que se ejecuta de inmediato en lugar de una con nombre:
useEffect(() => {
let ignore = false;
(async () => {
const data = await fetchUser(id);
if (!ignore) setUser(data);
})(); return () => { ignore = true; };
}, [id]);
Patrón 3: Cadenas simples de .then()
La documentación de referencia de React utiliza en realidad este estilo en su propio ejemplo de obtención de datos:
useEffect(() => {
let ignore = false;
setBio(null);
fetchBio(person).then(result => {
if (!ignore) {
setBio(result);
}
});
return () => {
ignore = true;
};
}, [person]);
Patrón 4: AbortController, cuando se desea una cancelación real
La bandera ignore impide que una respuesta obsoleta sobrescriba el estado, pero no evita que la solicitud en sí se ejecute hasta su finalización en segundo plano. Al utilizar fetch, se puede ir más allá y cancelar directamente la llamada de red. La documentación de React indica claramente que la limpieza debe abortar la solicitud o ignorar su resultado:
useEffect(() => {
const controller = new AbortController();
(async () => {
try {
const res = await fetch(`/api/users/${id}`, {
signal: controller.signal,
});
setUser(await res.json());
} catch (e) {
if (e.name !== 'AbortError') throw e;
}
})(); return () => controller.abort();
}, [id]);
¿Con cuál de estos debería comenzar en una entrevista? Empiece con el Patrón 1: es el que señalan tanto la advertencia del modo de desarrollo de React como la documentación oficial. Mencione explícitamente la bandera ignore, y luego agregue que, específicamente para las llamadas fetch, se podría ir un paso más allá y utilizar AbortController para cancelar la solicitud en lugar de simplemente descartar su resultado. Esa combinación —corrección, condiciones de carrera y tráfico de red desperdiciado— cabe en tres oraciones y abarca todo el problema.
Parte 7: StrictMode y TypeScript también detectan esto
StrictMode. Durante el desarrollo, StrictMode ejecuta deliberadamente un ciclo adicional de configuración y limpieza justo después del montaje inicial. Según la documentación de React, esto tiene como objetivo probar si tu lógica de limpieza refleja realmente a la de configuración. Un efecto asíncrono está destinado a fallar en esa verificación por su propia naturaleza, ya que nunca devuelve una función de limpieza utilizable. Esta doble invocación forzada es precisamente lo que revela suscripciones duplicadas, intervalos duplicados y solicitudes fetch duplicadas cuyas dos respuestas terminan siendo escritas en el estado.
TypeScript. Las definiciones de tipo para React integran directamente este contrato en el sistema de tipos:
// From @types/react (simplified)
type Destructor = () => void;
type EffectCallback = () => void | Destructor;
function useEffect(effect: EffectCallback, deps?: DependencyList): void;
Una función asíncrona tiene la firma () => Promise<void>, y ese tipo simplemente no es asignable a EffectCallback. Por lo tanto, en un proyecto de TypeScript, este error ni siquiera llega a superar la compilación. Eso ya merece ser destacado: la restricción es lo suficientemente fundamental como para aplicarse en tres lugares diferentes: el sistema de tipos, la advertencia en modo de desarrollo y el comportamiento real en tiempo de ejecución.
Parte 8: Alternativas modernas que vale la pena mencionar
Una respuesta más completa también reconoce que realizar operaciones de obtención de datos manualmente dentro de los efectos ya no se considera una buena práctica. Los propios documentos de React señalan que obtener datos directamente dentro de los efectos se vuelve repetitivo y dificulta la adición posterior de funcionalidades como el caché y la renderización en servidor. En su lugar, proponen algunas alternativas:
- Cargadores a nivel de framework. Si el proyecto utiliza un framework como Next.js o Remix, su mecanismo integrado de obtención de datos es preferible a los efectos del lado del cliente.
- Bibliotecas de caché del lado del cliente. La documentación de React menciona específicamente a TanStack Query, useSWR y React Router 6.4+. Estas ya se encargan de la deduplicación, el caché y las condiciones de competencia mencionadas anteriormente.
use introducida en React 19. use(promise) permite a un componente leer directamente el valor resuelto de una Promise, suspendiendo al componente mientras la Promise está pendiente y permitiendo que Suspense muestre un contenido alternativo. Una advertencia importante que vale la pena mencionar si se pregunta: una Promise creada recién durante el renderizado se vuelve a crear en cada renderizado, por lo que use está diseñado para consumir promesas provenientes de Componentes Servidor o gestionadas por un caché, y no llamadas fetch() simples escritas directamente en el código.Nada de esto sustituye la explicación fundamental. La pregunta sobre el contrato de efectos realmente prueba si entiendes qué hace React internamente con el valor devuelto. Pero terminar con algo como “y por eso, para la obtención real de datos, deberías usar TanStack Query o un cargador de frameworks” indica que también sabes cuál es la mejor práctica actual.
La respuesta para la entrevista en 30 segundos
Si solo debes recordar una versión de esta respuesta, mantén esta:
La propia función de callback debe ser síncrona; se define una función asíncrona dentro de ella, se invoca dicha función y luego se devuelve una función de limpieza que establece la bandera de ignorar en true o activa el método abort en un AbortController. Para la obtención de datos en el mundo real, un cargador de frameworks o una biblioteca como TanStack Query es la mejor opción, ya que estos manejan automáticamente estas condiciones de carrera.“La función de configuración de useEffect sigue un contrato síncrono: debe devolver o bien nada o una función de limpieza, ya que React necesita esa función de limpieza lista para ejecutarse justo antes de que el efecto se ejecute nuevamente y cada vez que el componente se desmonta. Una función asíncrona nunca puede cumplir con ese requisito, porque las funciones asíncronas siempre devuelven una Promise sin importar lo que se ejecute dentro de ellas. React almacena entonces esa Promise en el lugar exacto donde debería estar la función de limpieza: en modo desarrollo muestra la advertencia ‘Parece que escribió useEffect(async () => …)’, la verdadera lógica de limpieza nunca tiene oportunidad de ejecutarse, y más tarde, cuando React intenta llamar al valor almacenado como si fuera una función de limpieza, lanza un TypeError. En la práctica esto se manifiesta como suscripciones fugadas y condiciones de carrera, ya que sin una función de limpieza funcional no hay forma de ignorar o abortar una solicitud obsoleta. La solución es mantener”
Esta respuesta aborda el contrato, la regla subyacente de JavaScript, ambos modos concretos de fallo, la condición de carrera, la solución aceptada y hacia dónde se ha orientado el ecosistema moderno. Demuestra al entrevistador que el concepto no se memorizó simplemente como una regla, sino que se comprende el mecanismo detrás de ella, lo cual es precisamente lo que busca revelar una pregunta como esta.
Lecturas relacionadas
- Rethinking React State: Where Your Data Should Actually Live — Este artículo explica cómo reducir los errores en React al mover el estado a la URL, al DOM o a valores derivados en lugar de usar excesivamente useState.
- Stop Syncing State With useEffect: A Safer React Pattern — Aprende por qué usar useEffect para sincronizar el estado derivado causa condiciones de carrera y renders adicionales, y cómo reemplazarlo con la derivación en tiempo de renderizado y el atributo key.