Seis errores de sincronización asíncrona que se manifiestan como bugs en el renderizado de React
Aprenda a identificar seis patrones asíncronos de JavaScript, desde valores capturados obsoletos hasta devoluciones de Promise faltantes, que hacen que los componentes de React muestren un estado incorrecto o imposible.
Muchos problemas que se registran como “errores de React” en realidad no tienen su origen en React. Una lista que muestra resultados de una consulta antigua, un indicador de carga que nunca desaparece, un mensaje de éxito que aparece antes de que los datos estén listos: React es simplemente el lugar donde estos problemas se vuelven visibles. La verdadera causa suele estar un nivel más abajo, en la forma en que el JavaScript asíncrono transfiere valores a lo largo del tiempo.
El código asíncrono introduce el concepto de tiempo en un programa, y cada await o .then() representa un punto en el que las cosas pueden cambiar bajo nuestros pies. A continuación se presentan seis errores comunes relacionados con el tiempo, por qué cada uno genera un síntoma confuso en la interfaz de usuario y el pequeño cambio necesario para solucionarlo, de modo que puedan detectarse durante las revisiones de código.
Error 1: Las solicitudes obsoletas siguen escribiendo en el estado actual
Un cuadro de búsqueda es un ejemplo típico. El efecto mostrado a continuación ya aplaza la entrada durante 300 ms y elimina el temporizador al finalizar.
useEffect(() => {
if (!query) {
setResults([]);
return;
}
const timeoutId = setTimeout(async () => {
const results = await search(query);
setResults(results);
}, 300);
return () => {
clearTimeout(timeoutId);
};
}, [query]);
Imagínese ahora a alguien escribiendo “Async JavaScript Mistakes”. Escribe “Async”, hace una pausa lo suficientemente larga como para que expire el mecanismo de debounce, y se envía una solicitud. Luego sigue escribiendo, y se envía una segunda solicitud con toda la frase. El debounce redujo la cantidad de solicitudes, pero no evitó que dos de ellas estuvieran en tránsito al mismo tiempo.
El orden en el que las solicitudes salen del navegador está bajo su control. El orden en el que llegan las respuestas, no. Si la consulta más larga se resuelve primero y la consulta “Async” se resuelve después, la segunda llamada a setResults tiene prioridad, y la lista muestra resultados para “Async” mientras que en la entrada se lee claramente “Async JavaScript Mistakes”.
Nada funcionó mal aquí. Las redes pueden reordenar las respuestas, y el código nunca indicó a React cuál respuesta seguía siendo relevante. La solución habitual es cancelar el trabajo sobrescrito con un AbortController creado dentro del efecto y cancelado en su limpieza, de modo que una respuesta obsoleta o bien nunca llega o es ignorada. Si el flujo de datos ya está basado en RxJS, switchMap ofrece la misma semántica de “solo gana lo más reciente”. Para un análisis más profundo de este escenario específico, consulte arreglando condiciones de carrera que el retardo no puede resolver.
Error 2: Tomar decisiones con valores capturados antes de un await
Trate cada await como un límite. Lo que sabía antes de él es una instantánea; lo que sucede después se ejecuta en un momento posterior, posiblemente después de que haya cambiado otro estado.
const handlePublish = async () => {
const { canPublish } = permissions;
await saveDraft();
if (canPublish) {
publish();
}
};
Este manejador lee canPublish de permissions, espera a que se guarde el borrador y luego decide si publicarlo. El problema es que la decisión se basa en un valor que era verdadero al inicio. Si los permisos del usuario fueron revocados mientras se ejecutaba saveDraft(), la constante local sigue indicando que sí es posible publicarlo.
Esta misma situación ocurre con los elementos seleccionados, los filtros activos, los parámetros de ruta, el contenido del editor y muchos otros aspectos del estado. Cuando una decisión después de un await depende del estado actual de la aplicación, vuelva a leer ese estado después del límite (desde una referencia, un almacén o una solicitud nueva) en lugar de confiar en la copia anterior.
Error 3: Suponer que el resto de la función siempre se ejecuta
Aquí hay una bandera de carga envuelta alrededor de una solicitud fetch.
setLoading(true);
const dashboard = await getDashboard();
setDashboard(dashboard);
setLoading(false);
Si getDashboard() falla, la ejecución sale de la función en el punto del await, y setLoading(false) nunca se ejecuta. El indicador de carga permanece en la pantalla indefinidamente.
Cuando algún componente del modelo de estado representa la duración de una operación asíncrona, su reinicio debe ocurrir tanto en el caso de éxito como en el de error. Un bloque finally expresa directamente esa intención:
setLoading(true);
try {
const dashboard = await getDashboard();
setDashboard(dashboard);
} finally {
setLoading(false);
}
Obsérvese que el bloque try aún no tiene un catch. El error sigue propagándose a quien llamó este código, lo cual suele ser lo deseado, mientras que la bandera de carga se asegura de desactivarse. Añada un catch solo si este es el lugar adecuado para convertir el error en una interfaz de usuario, como un mensaje de error.
Error 4: Solicitudes independientes que generan estados de pantalla incoherentes
Ejecutar solicitudes no relacionadas en paralelo suele ser completamente razonable.
getProfile().then(setProfile);
getPermissions().then(setPermissions);
getPreferences().then(setPreferences);
Cada respuesta se almacena en su propio estado en el momento en que llega. Eso significa que React puede renderizar cualquier combinación posible: un perfil sin permisos ni preferencias, permisos y preferencias sin perfil, y así sucesivamente, en cualquier orden que genere la red.
Algunas de esas combinaciones pueden ser sin sentido o incluso peligrosas para tu pantalla. Cuando varias respuestas juntas describen un estado de pantalla coherente, las solicitudes pueden mantenerse en paralelo mientras un único responsable reúne el resultado, por ejemplo esperando a Promise.all y aplicando todo en una sola actualización de estado, o modelando la pantalla como un reducer con estados explícitos de carga, listo y error. La obtención paralela de datos y el estado independiente de la interfaz son dos decisiones separadas.
Error 5: Construir el estado siguiente a partir de una instantánea obsoleta
Este error se oculta fácilmente en los manejadores de eventos.
const handleAdd = async () => {
await saveItem(newItem);
setItems([...items, newItem]);
};
items es lo que mantenía el componente cuando comenzó a ejecutarse handleAdd. Mientras saveItem() estaba en proceso, otra acción podría haber añadido o eliminado elementos. Cuando el manejador vuelve a ejecutarse, utiliza el array antiguo y sobrescribe esos cambios más recientes.
Cada vez que el valor siguiente depende del anterior, deja que React proporcione el valor previo a través del formulario de actualización:
setItems(current => [...current, newItem]);
El actualizador se ejecuta con el estado más reciente comprometido en el momento en que se procesa la actualización, por lo que se preservan los cambios concurrentes. Las cierres obsoletas no son solo un problema de efectos: cualquier callback asíncrono puede retener valores por más tiempo del esperado.
Errore 6: Romper una cadena de promesas al no devolver
Esta cadena parece estrictamente secuencial: guardar, luego actualizar los widgets, y finalmente marcar como guardado.
saveDashboard()
.then(() => refreshWidgets())
.then(() => setSaved(true));
Ahora observa cómo podría escribirse refreshWidgets:
const refreshWidgets = () => {
getWidgets().then(setWidgets);
};
La función inicia una solicitud pero devuelve undefined, y no la Promise. Desde el punto de vista de la cadena externa, refreshWidgets() terminó al instante, por lo que el siguiente .then se ejecuta de inmediato y setSaved(true) puede activarse mientras los widgets siguen cargándose.
La solución consiste en usar una sola palabra clave para devolver la Promise, de modo que la cadena pueda esperar a que se resuelva:
const refreshWidgets = () => {
return getWidgets().then(setWidgets);
};
Una función async logra lo mismo de forma implícita, ya que siempre devuelve una Promise que se resuelve cuando se completa su cuerpo:
const refreshWidgets = async () => {
const widgets = await getWidgets();
setWidgets(widgets);
};
En la interfaz de usuario, este error puede parecer un mensaje de éxito que aparece demasiado pronto, datos obsoletos que permanecen en la pantalla o una redirección que ocurre antes de que se complete el actualizado. Ninguno de estos casos indica claramente la ausencia de un return. Las reglas de linting de TypeScript, como @typescript-eslint/no-floating-promises, pueden detectar automáticamente muchos de estos casos.
Puntos clave
La diferencia entre React y JavaScript no siempre es evidente. React renderiza el estado, pero el JavaScript asíncrono decide cuándo llega ese estado y si sigue siendo actual, obsoleto o inconsistente para entonces. Al revisar código asíncrono en componentes, tres preguntas ayudan a identificar la mayoría de estos errores:
- ¿Cuándo se capturó este valor, y podría haber cambiado durante un
await?
Lecturas relacionadas
- Arreglando errores en el manejo de errores de Async/Await en código producción de Node.js — Aprenda cinco errores comunes al manejar errores con async/await en JavaScript y Node.js que causan fallos silenciosos y condiciones de carrera, además de soluciones concretas.
- Localhost no es producción: Diagnosticando aplicaciones React que fallan al desplegar — Aprenda por qué una aplicación React que funciona bien en su máquina falla una vez desplegada, y cómo rastrear URLs de API, variables de entorno, CORS, enrutamiento, recursos y autenticación hasta la capa que causa el fallo.
- ¿Instantánea o valor en tiempo real? Cómo decidir qué ven las llamadas de retorno de JavaScript diferidas — Aprenda por qué los cierres mantienen el acceso a las variables de contexto en lugar de sus copias, y cómo elegir entre datos instantáneos y en tiempo real en temporizadores, efectos de React, listeners y código asíncrono.