Inicio / Artículos / ¿Instantánea o valor en tiempo real? Cómo decidir qué ven las devoluciones de llamada de JavaScript retrasadas

¿Instantánea o valor en tiempo real? Cómo decidir qué ven las devoluciones de llamada de JavaScript retrasadas

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 datos en tiempo real en temporizadores, efectos de React, listeners y código asíncrono.

3701 palabras

Un botón actúa sobre los datos de una renderización anterior. Un temporizador registra un valor que no existía cuando se programó. Cada manejador creado en un bucle parece pertenecer al último elemento. Una respuesta lenta sobrescribe la pantalla con resultados de una página que el usuario ya ha dejado. Estos errores suelen clasificarse como “problemas de cierre”, pero esa etiqueta rara vez ayuda a nadie a solucionarlos. Este artículo reemplaza esa etiqueta por un modelo preciso: un cierre mantiene el acceso a las vinculaciones de variables, no copias de los valores, y cada tarea diferida necesita una decisión explícita sobre si debe ver una instantánea o el valor en tiempo real.

La regla detrás de cada error de cierre

Los cierres no son impredecibles. Una función de JavaScript mantiene una referencia al entorno léxico en el que fue creada, y cada vez que su cuerpo hace referencia a una variable externa, ese nombre se busca en el entorno en el momento en que se ejecuta el código. La palabra clave es acceso. La función no recibe una copia congelada de todo lo visible cuando se definió; mantiene las mismas asociaciones, y si una de esas asociaciones se vuelve a asignar más tarde, la función leerá el nuevo valor.

Existen dos maneras opuestas de cometer este error. La primera es esperar un estado instantáneo cuando en realidad el código creó un enlace activo a una variable que cambia. La segunda, común en los frameworks de renderizado, es esperar el valor más reciente cuando la función de callback se creó dentro de un ámbito anterior cuyas vinculaciones nunca volverán a cambiar. Ambos errores provienen de la misma omisión: nadie decidió en qué momento debería realizarse el trabajo diferido.

Una vez que esa decisión es explícita, el comportamiento deja de parecer misterioso. La función de callback hace exactamente lo que el programa le indicó hacer. El problema radica en que el programa dijo algo distinto a lo que realmente quería decir el desarrollador.

Acceso, no una fotografía

El ejemplo de cierre del libro de texto muestra una función interna que sigue utilizando una variable externa después de que la función externa haya retornado. Esto demuestra que el entorno persiste más allá de la llamada, pero fomenta inadvertidamente una intuición errónea: que la función interna almacena el valor que vio en el momento de su creación. Una pequeña variante pone de manifiesto la diferencia. Aquí, el registrador se crea mientras message es "Starting", y la variable se vuelve a asignar antes de que la función generadora retorne.

function createLogger() {
  let message = "Starting";

  const logMessage = () => {
    console.log(message);
  };
  message = "Finished";
  return logMessage;
}
const logger = createLogger();
logger();

Al llamar a logger() se imprime Finished. La función de flecha nunca guardó la cadena "Starting"; guardó el enlace a message, y para cuando se ejecutó ese enlace apuntaba a una cadena diferente.

Esto es una característica, no un defecto. El estado mutable privado en contadores, funciones de fábrica, cachés de memorización y el patrón de módulos dependen todos de que un cierre pueda ver las actualizaciones de sus propias variables. Los problemas solo surgen cuando alguien cree que la función de callback está vinculada al valor anterior en lugar de a la variable.

Las primitivas facilitan este malentendido, ya que las cadenas de texto y los números parecen autónomos, y parece natural que una función simplemente mantenga “la cadena de texto”. Pero el cuerpo de la función contiene un nombre, no un valor, y los nombres se resuelven cuando se ejecuta el código. Si desea conocer más a fondo cómo se realiza esa búsqueda a través de los ámbitos anidados, cómo la cadena de ámbitos de JavaScript resuelve realmente las variables explica los mecanismos correspondientes.

La práctica recomendada consiste en hacerse una sola pregunta cada vez que se vaya a ejecutar un callback más tarde y este haga referencia a algo del exterior: ¿debe utilizar el valor tal como estaba cuando se creó el callback, o tal como está cuando se ejecuta? La respuesta indica si es necesario capturar una instantánea, leer la referencia actual o pasar el valor como argumento. Sin esta pregunta, el código sigue siendo JavaScript correcto, pero su comportamiento es accidental.

El error del bucle se relaciona con el vínculo de identidad

El error de cierre más conocido involucra un bucle for declarado con var que programa varios tiempos de espera. Cada callback parece estar vinculado a su propia iteración.

for (var index = 0; index < 3; index++) {
  setTimeout(() => {
    console.log(index);
  }, 100);
}

Imprime 3 tres veces. La declaración de var tiene ámbito de función, por lo que todo el bucle comparte exactamente una única asociación de index. Las tres funciones de callback acceden a esa misma asociación, y para cuando se activan los temporizadores, el bucle ya ha finalizado y dejado el valor en 3.

Al cambiar la declaración a let se modifica el resultado, ya que el lenguaje crea una nueva asociación para cada iteración de un bucle for con un encabezado let y copia el valor actual en ella.

for (let index = 0; index < 3; index++) {
  setTimeout(() => {
    console.log(index);
  }, 100);
}

Esta versión imprime 0, 1 y 2, ya que cada función de callback ahora hace referencia a un index diferente.

El resumen habitual es “let soluciona los cierres”, pero eso oculta la verdadera lección. Los cierres se comportaron de manera idéntica en ambos bucles. En el primero, a tres funciones de callback se les asignó deliberadamente una variable compartida y mutable; en el segundo, cada una recibió la suya propia. Lo que cambió fue la identidad de enlace, no el comportamiento del cierre.

Esa distinción es importante porque el mismo error persiste sin usar var. Si declaras una variable mutable con let fuera del bucle, la actualizas en cada iteración y la lees dentro de las funciones de callback, todas ellas volverán a ver solo el valor final. Cambiar una palabra clave no ayuda si el código sigue apuntando a varias funciones de callback hacia una misma fuente mutable.

La mejor práctica es preguntarse si los callbacks deben compartir estado. Si todos deben observar un valor en evolución, una única vinculación es la adecuada. Si cada uno debe recordar datos específicos de su iteración, cada uno necesita su propia vinculación o un argumento explícito. Al ver varios callbacks en el código, es tentador asumir que cada uno posee las variables que menciona; el ámbito léxico solo indica dónde se buscan los nombres, no quién los posee.

Cuando el tiempo separa la creación de la ejecución

Las sorpresas causadas por los cierres funcionales empeoran cuando existe un intervalo entre la creación de una función y su ejecución: un temporizador, una transmisión por red, una acción del usuario, una tarea en cola. Cualquier cosa que la función lea desde el exterior puede cambiar durante ese intervalo. Considere una rutina de guardado que depende de un ID de proyecto a nivel de módulo.

let activeProjectId = 42;

async function saveChanges(changes) {
  await saveProject(activeProjectId, changes);
}

activeProjectId = 84;

Si esto es correcto depende del momento en que ocurre. Los argumentos se evalúan cuando se llama a una función, por lo que si saveChanges se ejecuta antes de la reasignación, activeProjectId se lee de forma síncrona y el número 42 es lo que se pasa a saveProject, aunque esa llamada luego espere. Pero cualquier función de callback que lea activeProjectId después de algún retraso verá el valor que la variable contenga en ese momento, posiblemente 84, y guardará los datos en un proyecto completamente diferente.

Por esta razón, la expresión “las cierres capturan valores” es una abreviatura peligrosa. En algunos códigos un valor se copia en un argumento antes de la pausa; en otros códigos se lee una referencia compartida después de ella. Dos funciones pueden parecer casi idénticas al seguir reglas diferentes en cuanto al tiempo.

Las interfaces de usuario están repletas de este patrón. Imagine un cuadro de diálogo de confirmación abierto para un registro. El usuario navega a otro registro y luego hace clic en confirmar. Si el manejador lee la variable “registro actual”, elimina el registro que se muestra en pantalla y no el para el cual se abrió el cuadro de diálogo. El cierre funciona perfectamente; el problema es que el producto resultante está incorrecto, ya que la acción debería haber mantenido el contexto con el que comenzó.

La solución no consiste automáticamente en “copiar la variable”. Primero hay que decidir en qué momento corresponde realizar la operación:

  • Una acción destructiva suele pertenecer al momento en que se inició y debe utilizar el id capturado en ese instante.
  • Un indicador de estado en tiempo real necesita el valor más reciente cada vez que se actualiza.
  • Una repetición programada para más tarde puede combinar ambos: el id de la operación original, pero también cualquier token de autenticación que sea válido en ese momento.

Los cierres obligan a los desarrolladores de JavaScript a razonar explícitamente sobre el tiempo. La pregunta importante no es solo qué variable lee la función de callback, sino qué versión de ella pretende utilizar el flujo de trabajo.

Los objetos mantienen estable la referencia mientras cambian sus contenidos

Cuando el enlace capturado apunta a un objeto, surge un nuevo nivel de confusión. Hacer que el enlace sea const no congela nada más que al propio enlace. Si el objeto es mutable, una función de callback ejecutada posteriormente aún puede observar todos los cambios realizados a través de la referencia compartida.

const settings = {
  retries: 2,
};

setTimeout(() => {
  console.log(settings.retries);
}, 100);

settings.retries = 5;

El temporizador muestra 5. La constante settings nunca cambió el objeto al que se refiere, pero la propiedad retries del objeto fue actualizada antes de que se ejecutara la función de callback.

La consola del navegador puede añadir confusión. Registra un objeto antes de iniciar algún trabajo asíncrono, lo expandes más tarde en las herramientas de desarrollador y ves campos que fueron modificados después de que se ejecutó la instrucción de registro. Varias consolas muestran una vista en tiempo real del objeto al expandirlo, en lugar de un registro de su estado en el momento del registro, lo que hace que parezca como si el registro hubiera avanzado en el tiempo. Registrar JSON.stringify(obj) o un clon estructurado es una forma rápida de obtener una verdadera instantánea mientras se depura.

Crear una captura real en código requiere más que solo una variable nueva, y la profundidad de la copia depende de la estructura de lo que se desea proteger. Una copia superficial, mediante spread o Object.assign, separa las propiedades de nivel superior pero sigue compartiendo objetos y arrays anidados. Una clonación profunda separa aún más elementos, pero puede ser costosa, eliminar prototipos y métodos de clases, y duplicar referencias que deberían compartirse.

A menudo, la opción más limpia es no clonar en absoluto, sino extraer solo los valores inmutables pequeños que la operación necesita.

const retryLimit = settings.retries;

setTimeout(() => {
  console.log(retryLimit);
}, 100);

El callback ahora lee un valor cuya referencia nunca cambiará, y lo que es igualmente importante, el código indica en qué contexto histórico depende el temporizador.

Trate con desconfianza el trabajo diferido que accede a objetos mutables grandes. Los contextos, los contenedores de configuración, los objetos de estado de componentes y las cachés compartidas son ejemplos comunes. Una función de callback que accede a alguno de ellos más tarde dependerá silenciosamente de cada mutación que haya ocurrido en el intervalo. Pasar una carga útil limitada al trabajo diferido constituye un contrato mucho más claro.

React muestra un fallo opuesto

En React, los errores por cierre suelen indicar otra dirección. La función de callback no lee un valor que sea demasiado reciente; sigue leyendo uno que es demasiado antiguo.

Cada renderizado de un componente funcional es una nueva llamada a función con sus propias vinculaciones locales para props, estado y valores derivados. Una función de callback creada durante un renderizado mantiene las vinculaciones de ese renderizado. Cuando el estado cambia, React llama nuevamente al componente y crea nuevas vinculaciones, pero cualquier función de callback del renderizado anterior que siga activa seguirá apuntando a las antiguas.

Los síntomas son conocidos:

  • Un intervalo creado en un renderizado registra indefinidamente un conteo desactualizado.
  • Un oyente de eventos registrado una sola vez sigue utilizando un prop del primer renderizado.
  • Un efecto con una lista de dependencias incompleta sigue llamando a una función que mantiene un estado obsoleto.

A esto se le denomina cierre obsoleto, pero el cierre en sí no está dañado. Es leal a su renderizado original, y nada en el lenguaje lo hace avanzar cuando React vuelve a renderizar.

Esto podría parecer contradictorio con los ejemplos anteriores, donde las clausuras observaban sin problemas las actualizaciones. La diferencia, una vez más, radica en la identidad de los bindings. En el ejemplo del registrador había un binding que fue reasignado, y la clausura vio el nuevo valor. React no reasigna los bindings antiguos; crea entornos completamente nuevos en cada renderizado. La función de callback antigua permanece vinculada al entorno antiguo, cuyos valores nunca cambian.

Visto de esta manera, las soluciones se derivan de lo que se supone que debe hacer la función de callback:

  • Una actualización de estado que dependa del valor actual puede utilizar la forma funcional, como setCount(c => c + 1), de modo que React proporcione el estado más reciente.
  • Una suscripción que necesite el valor más actualizado puede ser recreada cuando cambien sus dependencias, o bien puede leer desde un ref mantenido intencionadamente.
  • Una función de callback que debe actuar sobre los valores del renderizado que la creó podría ya estar correcta tal como está escrita.
  • La pregunta sigue siendo la misma que antes: ¿debería este comportamiento diferido utilizar el estado histórico o el estado actual? Muchos errores en React surgen al responderla por accidente, editando un array de dependencias en lugar de razonar sobre qué es lo que la función debería hacer. Las versiones más recientes de React también ofrecen useEffectEvent para leer los valores más recientes dentro de un efecto sin tener que ejecutarlo nuevamente; cómo retirar el ref lastest-value con useEffectEvent explica ese patrón.

    Los arrays de dependencias describen la realidad, no las preferencias

    Una forma común de luchar contra los cierres obsoletos es ajustar el array de dependencias de un efecto hasta que su comportamiento sea correcto. Un valor se agrega porque el analizador de errores lo indica, se elimina porque el efecto se ejecuta con demasiada frecuencia, y eventualmente el array queda vacío porque el efecto “solo debería ejecutarse una vez”.

    Eso trata el array como un control de frecuencia. En realidad, es una declaración de qué valores de renderizado lee el cierre del efecto.

    Si un efecto utiliza una variable del renderizado circundante, esa variable forma parte de su cierre independientemente de si aparece en el array o no. Excluirla no elimina la dependencia; solo garantiza que el efecto siga utilizando la versión establecida por el último renderizado en el que se configuró.

    Incluir todas las dependencias puede generar un problema distinto: el efecto termina eliminando y recreando una suscripción, reiniciando un temporizador o volviendo a enviar una solicitud con mucha más frecuencia de la deseada. Esto se interpreta fácilmente como que el analizador es demasiado estricto. Con mayor frecuencia, es una señal de que el efecto está realizando más de una tarea, o de que un objeto o función del cual depende se está recreando en cada renderizado sin motivo alguno.

    Las soluciones típicas incluyen:

    • estabilizar una función de callback para que solo cambie cuando cambien sus propios parámetros
    • dividir un efecto en varios con responsabilidades más específicas
    • mover una función auxiliar dentro del efecto para que ya no sea una dependencia externa
    • utilizar una actualización de estado funcional en lugar de leer el estado directamente
    • preguntarse si la lógica realmente necesita ser un efecto

    El objetivo no es obligar a React a ejecutar el efecto a una frecuencia deseada. Se trata de proporcionarle al efecto un cierre cuya vida útil coincida con el comportamiento del cual es responsable. Cuando un efecto necesita valores actualizados sin tener que ser desmontado cada vez que cambian, dele una forma deliberada de leerlos, como un ref o un evento de efecto. Si debe reiniciarse cuando cambia un valor, ese valor debe estar en el array. Y si el efecto no tiene realmente uso para un valor, elimine la forma de leerlo en lugar de la dependencia.

    Los problemas con las dependencias son una señal de retroalimentación en el diseño. El comportamiento problemático surge cuando el código declara una vida útil para un cierre mientras que la funcionalidad necesita otra distinta.

    Los listeners de eventos sobreviven al contexto que los creó

    Los listeners crean intencionadamente un intervalo entre el registro y la ejecución. Se adjunta el manejador una sola vez, y se ejecuta cada vez que se dispara el evento, posiblemente mucho tiempo después de que las variables circundantes hayan cambiado.

    En JavaScript puro, un listener que lee una variable a nivel de módulo ve su valor más reciente. En un framework de componentes, un listener adjuntado durante una renderización inicial mantiene las vinculaciones de esa renderización. De cualquier manera, esta relación es fácil de pasar por alto, ya que el manejador solo se ejecuta cuando el usuario realiza alguna acción posteriormente.

    La limpieza añade otra dimensión. Si cada renderizado agrega un nuevo oyente sin eliminar al anterior, varios cierres terminan respondiendo al mismo evento, cada uno con una versión diferente del estado. Un solo clic puede entonces producir varios resultados provenientes de diferentes momentos en la historia de la aplicación. El resultado visible podría ser una actualización duplicada, un valor antiguo que vuelve a aparecer momentáneamente, o un manejador que se ejecuta después de que su componente ya se haya desmontado. La causa raíz en cada caso es que la vida útil del oyente nunca estuvo alineada con la vida útil del estado del cual depende.

    Un código de oyentes fiable hace explícita la propiedad:

    • Mantenga una referencia a la función exacta que registró, ya que removeEventListener necesita esa misma referencia.
  • Recrea el listener cuando cambie el comportamiento del cual depende, o haz que lea los valores actuales a través de un canal deliberado como una referencia o un almacén.
  • Elimina el listener cuando su propietario, ya sea un componente, un módulo o una función, deje de existir.
  • Nada de esto es una formalidad. Al registrar un callback se crea un vínculo entre eventos futuros y el entorno disponible en ese momento. Si ese vínculo debe terminar, el código tiene que hacerlo.

    Las respuestas asíncronas transfieren la intención anterior a una nueva pantalla

    Las solicitudes de red generan algunos de los errores por contexto obsoleto más costosos. Una solicitud comienza mientras el usuario está viendo una consulta de búsqueda, proyecto o ruta específica. Antes de que se complete, el usuario se mueve a otra parte. Cuando llega la respuesta, su callback escribe en el estado compartido utilizando el contexto que capturó al inicio.

    A veces ese contexto histórico es exactamente correcto. Una solicitud hecha para el proyecto 42 debe permanecer asociada a dicho proyecto incluso después de que el proyecto activo pase a ser 84. El peligro radica en permitir que ese resultado actualice una pantalla que ya ha pasado al proyecto 84. Recordar la solicitud original es precisamente lo que hace bien el mecanismo de cierre; asumir que automáticamente se sigue necesitando una respuesta completada es donde falla la lógica.

    Por esta razón, el razonamiento de cierre y la gestión asíncrona van de la mano. Una función de callback puede contener valores históricos perfectamente correctos y, aun así, no tener derecho a actualizar el destino al que está dirigida. Las medidas de protección comunes incluyen:

    • un ID de solicitud o número de secuencia que se compara antes de aplicar un resultado
    • una señal AbortController que cancela el trabajo cuando el usuario pasa a otra tarea
    • una verificación para asegurarse de que la ruta o selección actual siga coincidiendo con la solicitud
  • almacenar los resultados bajo una clave correspondiente al recurso al que pertenecen, en lugar de en un único slot “actual”
  • Simplemente reescribiendo la función de callback para leer el proyecto activo más reciente puede empeorar las cosas: la respuesta del proyecto 42 se almacenaría bajo el proyecto 84. Leer el estado actual no es una solución universal. Mantenga intacta la identidad de la solicitud y verifique que el destino todavía necesite el resultado antes de escribirlo.

    Mantenga separados los dos contextos. El contexto de la operación pertenece a los datos por los que se realizó la solicitud. El contexto de la interfaz pertenece a lo que el usuario está viendo en ese momento. La pantalla solo debe actualizarse cuando ambos sigan coincidiendo. Sin esta separación, las funciones de callback parecen viajar en el tiempo, entregando información válida de un momento anterior a una vista que ya ha avanzado.

    Elegir entre captura de estado y acceso en tiempo real

    La mayoría de estos errores se vuelven sencillos una vez que el equipo nombra la relación que desea establecer.

    Un snapshot implica que el trabajo diferido utiliza los datos tal como estaban en el momento en que se creó. Esto es aplicable a los IDs de transacción, el ID del registro seleccionado, los valores de los formularios enviados, el contexto de auditoría y las comandos que deben mantener su intención original. Se implementa pasando valores como argumentos, creando cargas útiles inmutables o copiando solo los campos específicos que necesita la operación.

    Acceso en tiempo real significa que el trabajo diferido utiliza el valor más reciente en el momento en que se ejecuta. Esto es aplicable al estado de la conexión, la configuración más reciente, algunos estados actuales de la interfaz de usuario dentro de los manejadores de eventos y los valores de coordinación mutables. Se implementa a través de un enlace compartido, una referencia, un accesorio de almacenamiento o alguna otra fuente que sea explícitamente “actual”.

    Ninguno es generalmente más seguro. Aparecen errores cuando el código implementa uno mientras el desarrollador asume el otro.

    Dos hábitos hacen que la elección sea visible en el código. Primero, evite las cierres que acceden de forma indiscriminada a todo lo que está en el ámbito; un cierre amplio oculta sus dependencias, por lo que el lector no puede ver cuáles deben quedar fijos y cuáles deben mantenerse actualizados. Segundo, prefiera funciones pequeñas y cargas de trabajo limitadas, lo que reduce la cantidad de variables cuya semántica temporal debe ser considerada por alguien. Ambos mejoran la fiabilidad asíncrona por la misma razón: menos relaciones implícitas con el tiempo.

    Una lista de verificación rápida para cualquier función de callback que se ejecute más tarde:

    • ¿Qué variables externas lee?
    • Para cada una, ¿debe ver su valor en el momento de la creación o en el momento de la ejecución?
    • ¿Hay alguna de ellas que sea un objeto mutable cuyo contenido podría cambiar entre ambos momentos?
  • ¿Quién es el propietario de este callback y cuándo debería dejar de ejecutarse?
  • Si escribe los resultados en algún lugar, ¿ese destino sigue perteneciendo al mismo contexto?
  • Conclusión

    Las cierres en JavaScript son deterministas. Siguen el ámbito léxico, conservan los enlaces y mantienen los entornos activos mientras una función accesible los necesite. Lo que les da la sensación de ser problemáticos es tratar una variable como si tuviera un único valor significativo a lo largo del tiempo.

    Cada escenario anterior es una variante de una misma pregunta: ¿en qué momento debería pertenecer este callback? Un bucle puede proporcionar a todos sus callbacks un mismo enlace compartido. Un temporizador puede leer un objeto después de que haya sido modificado. Un callback de React puede seguir vinculado a una renderización anterior. Un listener puede sobrevivir al estado para el cual fue creado. Una respuesta asíncrona puede llevar la intención original a una vista que ya ha evolucionado.

    Los desarrolladores experimentados siguen encontrándose con estos errores porque las aplicaciones modernas posponen constantemente la ejecución de tareas. Los tiempos de espera, las cadenas de promesas, los eventos DOM, las suscripciones, las actualizaciones repetidas, las colas de tareas y las respuestas HTTP crean siempre un intervalo entre la definición de una función y su ejecución, y cuanto más largo sea ese intervalo, más cambia el entorno a su alrededor. La solución no consiste en memorizar otra definición de cierres. Se trata de hacer explícitos el momento y quien es responsable: elegir deliberadamente entre acceso a estado inmediato o a un snapshot, conservar solo los valores históricos que necesite la tarea, proporcionar al estado actual una única fuente intencional, vincular la vida útil de cada función de callback a quien sea responsable de ella y evitar que las tareas obsoletas escriban en lugares que ya no controlan. Cuando esas decisiones son visibles en el código, los cierres dejan de sorprender, porque finalmente están conectados con el momento que se pretendía originalmente.

    Lecturas relacionadas