Tema representativo de entrevista

Entrevista de Frontend: ¿Cómo depurar y solucionar cierres obsoletos (stale closures) en React?

FrontendDifícil
Equipo editorial de Offer.ccPublicado Actualizado

Pregunta

Un componente de React 19.2 se suscribe a órdenes en vivo, pero su callback sigue leyendo el tenant, el filtro y el contador iniciales. Explica por qué sucede esto, predice los errores y corrige el componente sin reconectarse ante cada cambio de estado. Compara dependencias de efectos, actualizaciones funcionales de estado, useEffectEvent, refs y cancelación asíncrona.

Problema y contexto de aplicación

Considera un panel de control en React 19.2 que se suscribe a órdenes en vivo:

tsx
function LiveOrders({ tenantId }: { tenantId: string }) {
  const [filter, setFilter] = useState("all")
  const [count, setCount] = useState(0)

  useEffect(() => {
    const connection = connect(tenantId)

    connection.on("order", (order) => {
      if (matches(order, filter)) {
        setCount(count + 1)
      }
    })

    return () => connection.close()
  }, [])

  // Rendering and controls omitted.
}

Después de que el usuario cambia el filtro, el callback todavía aplica "all". Varias órdenes en una ráfaga pueden dejar el contador en 1. Si el componente recibe un tenantId diferente, permanece conectado al primer tenant. Agregar cada valor referenciado a la lista de dependencias parece solucionar la frescura, pero luego cada cambio de contador o filtro cierra y vuelve a crear la conexión.

Explica por qué ocurren estos fallos, cómo demostrar qué valor está obsoleto y cómo preservar el tiempo de vida previsto de la suscripción mientras los callbacks observan los últimos valores confirmados (committed).

Esta es una pregunta realista de entrevista de frontend porque las guías públicas de entrevistas de React, tanto en inglés como en chino, evalúan explícitamente cierres obsoletos, reglas de Hooks y dependencias de useEffect. La habilidad más profunda no es memorizar una solución alternativa. Es decidir si un valor debe reiniciar un Effect, participar en una transición de estado o ser leído de forma no reactiva por un callback perteneciente a un Effect.

Qué está evaluando el entrevistador

Primero, ¿puede el candidato explicar el mecanismo con precisión? Cada renderizado recibe una instantánea (snapshot) de props y estado. Las funciones creadas durante ese renderizado capturan esa instantánea en su cierre (closure). React no muta las variables locales filter, count o tenantId después de un renderizado posterior. Si un sistema externo retiene el primer callback, ese callback continúa leyendo los valores del primer renderizado. El cierre se comporta normalmente; el contrato de sincronización declarado del Effect está incompleto.

Segundo, ¿puede el candidato separar cuatro requisitos diferentes?

RequisitoHerramienta correctaQué cambia
El recurso externo debe seguir un valor reactivoDependencia del EffectLimpia y resincroniza el Effect
El siguiente estado se deriva del estado anteriorActualizador funcionalCalcula a partir del estado actual en cola de React
Un callback perteneciente a un Effect necesita los últimos valores confirmados sin resuscribirseuseEffectEvent en React 19.2Lee las props y el estado actuales de forma no reactiva
Los datos mutables imperativos y no renderizados necesitan una identidad estableRefAlmacena un valor mutable sincronizado manualmente

Tercero, ¿puede el candidato resistirse a soluciones falsas? useCallback(fn, []) preserva la identidad de la función, pero también preserva el cierre de su primer renderizado. Suprimir exhaustive-deps oculta la evidencia en lugar de cambiar la semántica. Recrear un WebSocket para cada incremento del contador puede ser fresco, pero representa un tiempo de vida del recurso incorrecto.

Finalmente, ¿puede el candidato cubrir la limpieza y el orden asíncrono? Un cierre fresco no cancela una solicitud antigua, no previene una respuesta fuera de orden ni elimina un listener de eventos filtrado. Esas son obligaciones de concurrencia y ciclo de vida independientes.

Preguntas de aclaración antes de responder

  • ¿Qué valores definen la identidad de la suscripción? Si tenantId selecciona el flujo

del lado del servidor, cambiarlo debe reconectar. Un filtro de visualización normalmente no debería hacerlo.

  • ¿Deben reevaluarse los eventos históricos cuando cambia el filtro? Esto decide si el

filtro pertenece únicamente al callback o si también desencadena una nueva consulta o suscripción.

  • ¿El callback calcula a partir del estado anterior? Incrementar un contador debe usar un

actualizador funcional incluso cuando el callback lee valores frescos por lo demás.

  • ¿Qué versión de React está desplegada? useEffectEvent está disponible en React 19.2. En una versión

anterior, una ref cuidadosamente sincronizada puede ser la alternativa de compatibilidad.

  • ¿Puede la API externa reemplazar su listener sin reconectarse? Algunas bibliotecas exponen

operaciones de subscribe y updateHandler separadas, lo que puede admitir un tiempo de vida mínimo diferente.

  • ¿El callback inicia solicitudes asíncronas? Si es así, define la cancelación o una verificación

de generación de solicitudes además de resolver los valores capturados.

  • ¿Está habilitado el Modo Estricto (Strict Mode) en desarrollo? Su ciclo adicional de configuración y limpieza puede exponer limpiezas

faltantes, pero no crea cierres obsoletos.

Marco de respuesta de 30 segundos

“Un callback de React lee la instantánea de props y estado del renderizado que lo creó. Aquí el Effect se ejecuta solo una vez, por lo que la conexión retiene el tenant, el filtro y el contador iniciales. Haría que la suscripción dependa de tenantId, porque ese valor cambia el recurso al que nos conectamos. Actualizaría el contador con setCount(current => current + 1), porque se deriva del estado anterior. Para que el callback de órdenes lea el filtro más reciente sin reconectarse, en React 19.2 colocaría esa lógica no reactiva en useEffectEvent y la llamaría desde el listener perteneciente al Effect. No suprimiría el linter de dependencias ni usaría useCallback([]) como una solución de frescura. Las refs son una opción de compatibilidad manual para datos mutables no renderizados, y los resultados asíncronos aún necesitan cancelación o versionado”.

Análisis paso a paso a profundidad

Paso uno: modelar cada renderizado como una instantánea inmutable.

Durante el primer renderizado, asume que estos valores son:

text
tenantId = "tenant-a"
filter = "all"
count = 0

El Effect crea una conexión y un callback que hace referencia exactamente a esas variables locales. Más adelante, setFilter("paid") produce otro renderizado con una nueva variable filter. No edita la variable capturada por el callback original. Dado que la lista de dependencias vacía indica que el Effect nunca necesita sincronizarse nuevamente, la conexión externa continúa siendo dueña del callback original.

Eso predice los tres errores sin conjeturas:

  1. matches(order, filter) sigue usando "all".
  2. Cada evento coincidente llama a setCount(0 + 1), por lo que los eventos repetidos reemplazan el estado con 1 en lugar

de componer incrementos.

  1. La conexión permanece asociada con "tenant-a" después de un cambio de prop.

Un registro de depuración útil incluye un número de secuencia de renderizado, los valores vistos durante el renderizado y los valores vistos dentro del callback retenido. Registra el ID de la conexión por separado. Esto revela si el callback está obsoleto, si la suscripción no se reinició o si el servidor envió datos inesperados.

Paso dos: clasificar cada lectura por semántica antes de editar dependencias.

Pregunta por qué el Effect lee cada valor:

  • tenantId determina qué flujo externo se sincroniza. Es reactivo y pertenece a

la lista de dependencias.

  • count solo se necesita para calcular el siguiente conteo. Reemplaza la lectura con un actualizador funcional,

de modo que ya no sea necesario capturarlo.

  • filter afecta cómo se maneja un evento futuro, pero cambiarlo no debería desmantelar la

conexión del tenant. El listener necesita una lectura del valor más reciente sin hacer que la suscripción sea reactiva al filtro.

Esta clasificación evita los dos extremos: una lista de dependencias vacía y una lista de dependencias que reinicia un recurso costoso ante cada cambio relacionado con el renderizado.

Paso tres: corregir las transiciones de estado con actualizadores funcionales.

Cambia esto:

tsx
setCount(count + 1)

a esto:

tsx
setCount((current) => current + 1)

React pone en cola el actualizador y le pasa el estado pendiente actual cuando se calcula el siguiente renderizado. Por lo tanto, diez eventos componen diez incrementos incluso si su callback se registró antes o si React procesa las actualizaciones por lotes (batching). Esto solo corrige la dependencia de count. No hace que filter o tenantId sean frescos.

Paso cuatro: declarar dependencias que genuinamente resincronizan el recurso.

La identidad de la conexión incluye a tenantId, por lo que el Effect debe depender de él. En un cambio de tenant, React ejecuta primero la limpieza anterior y luego establece la nueva conexión. La limpieza debe eliminar los listeners o cerrar la conexión antigua para que los eventos del tenant anterior no puedan actualizar la pantalla actual.

Agregar filter y count técnicamente daría a cada nuevo callback los valores actuales, pero también redefiniría el tiempo de vida de la conexión. Una actualización del contador provocaría una desconexión y reconexión. Eso puede perder eventos, duplicar eventos durante limpiezas superpuestas, restablecer el estado del cursor del lado del servidor y generar una carga innecesaria. Una lista de dependencias es una especificación de comportamiento, no una pista de rendimiento que se deba editar hasta que el linter se quede en silencio.

Paso cinco: usar un Effect Event para lecturas no reactivas del valor más reciente en React 19.2.

El componente corregido puede mantener separados el tiempo de vida de la conexión y la frescura del callback:

tsx
function LiveOrders({ tenantId }: { tenantId: string }) {
  const [filter, setFilter] = useState("all")
  const [count, setCount] = useState(0)

  const onOrder = useEffectEvent((order: Order) => {
    if (matches(order, filter)) {
      setCount((current) => current + 1)
    }
  })

  useEffect(() => {
    const connection = connect(tenantId)
    connection.on("order", onOrder)

    return () => connection.close()
  }, [tenantId])

  // Rendering and controls omitted.
}

onOrder siempre observa el filter confirmado más reciente, mientras que el Effect todavía se resincroniza solo cuando cambia tenantId. Un Effect Event se llama desde un Effect o código conectado a ese Effect, como el listener que registra. No es un reemplazo general de manejadores de eventos, no debe pasarse arbitrariamente a través del árbol de componentes y no debe usarse para ocultar un valor que realmente debería reiniciar la sincronización.

No agregues el Effect Event a la lista de dependencias. El linting actual de React comprende su rol no reactivo. Si el linter informa un valor diferente, trata esa advertencia como retroalimentación de diseño y cambia la estructura del código en lugar de deshabilitar la regla.

Paso seis: comprender cuándo es adecuada una ref y cuánto cuesta.

Antes de React 19.2, un patrón de compatibilidad común almacena el valor más reciente en una ref:

tsx
const filterRef = useRef(filter)

useEffect(() => {
  filterRef.current = filter
}, [filter])

useEffect(() => {
  const connection = connect(tenantId)
  connection.on("order", (order) => {
    if (matches(order, filterRef.current)) {
      setCount((current) => current + 1)
    }
  })

  return () => connection.close()
}, [tenantId])

El objeto ref es estable a través de los renderizados, y cambiar current no desencadena el renderizado. Eso hace que las refs sean adecuadas para valores imperativos como un manejador más reciente, un ID de temporizador o un identificador de API externa que no se muestra directamente en pantalla. El costo es la sincronización manual: el linter de dependencias no puede demostrar que filterRef.current se actualice correctamente, y un Effect de sincronización faltante reintroduce silenciosamente un comportamiento obsoleto. No muevas el estado mostrado en pantalla a una ref simplemente para evitar un renderizado, y no leas ni escribas refs durante el renderizado excepto para patrones de inicialización documentados.

Paso siete: mantener a useCallback y la memorización en su rol adecuado.

useCallback almacena en caché la identidad de una función hasta que cambia una de sus dependencias. Es útil cuando un hijo memorizado o una API externa se preocupa por la identidad. No proporciona los valores más recientes por sí mismo:

tsx
const onOrder = useCallback((order: Order) => {
  if (matches(order, filter)) {
    setCount((current) => current + 1)
  }
}, [])

Esta versión todavía captura el filtro inicial. Agregar [filter] actualiza la función, pero la conexión externa debe reemplazar su listener correctamente. Si la eliminación del listener requiere la misma identidad de función, la configuración y la limpieza deben usar la misma instancia de callback para ese renderizado. La memorización responde a una pregunta de identidad; no responde a la pregunta del tiempo de vida de la sincronización.

Paso ocho: resolver el ordenamiento asíncrono por separado.

Supongamos que el filtro más reciente inicia una solicitud. La solicitud "all" puede resolverse después de la solicitud más reciente "paid" y sobrescribir sus resultados. Incluso un callback con el filtro más reciente no puede evitar que esa solicitud anterior se complete. El Effect debe abortar el trabajo obsoleto con un AbortController cuando sea compatible, o asociar cada solicitud con una generación que se incrementa monótonamente y confirmar solo la última generación.

La limpieza también debe ser simétrica:

  • cerrar la conexión exacta creada por esa configuración;
  • eliminar el listener exacto registrado por esa configuración;
  • limpiar intervalos y temporizadores;
  • abortar o invalidar el trabajo asíncrono pendiente;
  • hacer que los callbacks tardíos sean inofensivos después de la limpieza.

La secuencia de configuración-limpieza-configuración solo para desarrollo de Strict Mode es evidencia útil aquí. Las conexiones duplicadas indican una limpieza incompleta. No es evidencia de que Strict Mode haya causado el cierre obsoleto.

Paso nueve: verificar el comportamiento cambiando una dimensión a la vez.

PruebaResultado esperado
Emitir tres órdenes coincidentes antes de que React vuelva a renderizarEl contador aumenta en tres
Cambiar filter, luego emitir una ordenEl listener aplica el nuevo filtro sin reconectarse
Cambiar tenantIdLa conexión antigua se cierra una vez; el nuevo tenant se conecta una vez
Emitir desde la conexión antigua después de la limpiezaLa UI actual no cambia
Resolver dos solicitudes en orden inversoSolo la solicitud más reciente puede confirmarse
Ejecutar bajo Strict Mode en desarrolloLa configuración y la limpieza permanecen simétricas; sin listener duplicado
Volver a habilitar la regla de linting de dependencias de HooksNo queda ninguna advertencia de dependencias suprimida o sin explicación

En producción, rastrea los conteos de configuración y limpieza de conexiones, IDs de conexiones activas, cambios de tenant, descartes de eventos tardíos y solicitudes abortadas. No registres cargas útiles privadas de órdenes simplemente para diagnosticar la frescura del callback.

Respuesta de muestra de alta calidad

“Comenzaría desde el modelo de renderizado de React. Cada renderizado obtiene una instantánea de props y estado, y una función creada en ese renderizado se cierra sobre esa instantánea. Dado que este Effect tiene una lista de dependencias vacía, la conexión externa conserva el callback del primer renderizado. Por lo tanto, ve el tenant, el filtro y el contador iniciales.

Clasificaría cada valor antes de cambiar la lista de dependencias. tenantId selecciona el recurso externo, por lo que pertenece a las dependencias y un cambio debe cerrar la conexión antigua y abrir la nueva. count solo se usa para derivar el siguiente estado, por lo que llamaría a setCount(current => current + 1). Eso hace que las actualizaciones en ráfaga se compongan a partir del estado en cola de React. El filter más reciente se necesita cuando se dispara un listener de órdenes perteneciente al Effect, pero no debería reiniciar la conexión. Como esta aplicación usa React 19.2, colocaría la lógica de filtrado en useEffectEvent y registraría ese Effect Event desde un Effect que depende solo de tenantId.

No suprimiría exhaustive-deps, porque la lista de dependencias describe la sincronización. No usaría useCallback([]) como una solución de frescura, porque retiene el cierre inicial. En una versión anterior de React, una ref sincronizada desde filter puede ser una alternativa de compatibilidad, pero eso es manual e invisible para el análisis de dependencias.

Finalmente, probaría incrementos en ráfaga, un cambio de filtro sin reconexión, un cambio de tenant con exactamente una limpieza y configuración, la llegada de un evento desde una conexión cerrada y el Strict Mode en desarrollo. Si los callbacks lanzan solicitudes, también abortaría o versionaría las solicitudes obsoletas, porque la frescura del cierre por sí sola no evita resultados fuera de orden”.

Errores comunes

  • Llamar error a todo cierre → se espera que los callbacks capturen una instantánea de renderizado →

identifica la discrepancia entre el callback retenido y la sincronización prevista.

  • Suprimir exhaustive-deps el código promete que las lecturas reactivas nunca importan mientras

sigue usándolas → cambia el código para que la lista de dependencias describa con veracidad el Effect.

  • Agregar cada valor de estado a las dependencias → la frescura se restaura recreando una suscripción

costosa después de cada actualización → separa la identidad del recurso de las lecturas más recientes del callback.

  • Usar useCallback(fn, []) la identidad estable también congela el primer cierre → **proporciona a

useCallback las dependencias correctas o usa la herramienta que coincida con la semántica deseada.**

  • Reemplazar el estado con una ref → las escrituras en ref no renderizan y pueden divergir de la UI visible →

mantén los datos mostrados en el estado y reserva las refs para datos mutables imperativos.

  • Usar useEffectEvent para ocultar una dependencia real → el Effect ya no reacciona cuando su

recurso externo debería cambiar → mantén los valores que definen el recurso en la lista de dependencias.

  • Corregir el filtro pero mantener setCount(count + 1) los eventos en ráfaga todavía reemplazan a partir de un

contador capturado → usa un actualizador funcional para el estado derivado del estado anterior.

  • Ignorar la limpieza y el orden de las solicitudes → los listeners filtrados y las respuestas tardías aún corrompen

la pantalla → elimina, cierra, aborta o versiona cada operación de acuerdo con su ciclo de vida.

Preguntas de seguimiento y respuestas

Pregunta de seguimiento 1: ¿Por qué setTimeout dentro de un manejador de clics ve un valor de estado antiguo?

El callback del temporizador pertenece al renderizado en el que ocurrió el clic. Actualizar el estado programa un nuevo renderizado; no muta la variable de estado local de ese manejador. Si se supone que la acción retrasada debe informar el valor en el momento del clic, la instantánea es correcta. Si debe usar el valor confirmado más reciente, modela ese requisito explícitamente con un Effect Event en el código relacionado con el Effect o una ref cuidadosamente mantenida para un callback imperativo.

Pregunta de seguimiento 2: ¿Por qué no poner filter en la lista de dependencias del Effect?

Eso es correcto solo si cambiar el filtro debería resincronizar el sistema externo. Si la suscripción del servidor en sí es específica del filtro, inclúyelo y reconéctate. En este escenario, el flujo del tenant sigue siendo el mismo y el filtro es una política local de procesamiento de eventos, por lo que reconectarse daría un tiempo de vida incorrecto. Establece la suposición del producto y de la API antes de elegir.

Pregunta de seguimiento 3: ¿useEffectEvent reemplaza a los manejadores de eventos ordinarios?

No. Un clic en un botón normalmente es manejado por un manejador de eventos creado durante el renderizado. Un Effect Event es para lógica no reactiva invocada por un Effect o código conectado a él, como un temporizador o el listener de una suscripción. Debe permanecer local al Effect que lo utiliza y no debe convertirse en un mecanismo general de transporte de callbacks.

Pregunta de seguimiento 4: ¿Puede un actualizador funcional resolver todos los cierres obsoletos?

No. Resuelve solo una transición de estado que se deriva del valor anterior de ese mismo estado. No puede hacer que una prop, otra variable de estado o una configuración externa sean actuales. En este ejemplo, corrige los incrementos del contador pero no la conexión del tenant ni la lectura del filtro.

Pregunta de seguimiento 5: ¿Cuándo es preferible una ref al estado?

Usa una ref cuando un valor deba persistir a través de los renderizados, los cambios no deban renderizar la UI y el valor se use de forma imperativa: un ID de temporizador, un nodo del DOM, un handle de conexión o una celda de compatibilidad para el valor más reciente. Si la salida renderizada depende de él, usa el estado. Una ref traslada la responsabilidad de la frescura al desarrollador, así que documenta y prueba su punto de sincronización.

Pregunta de seguimiento 6: ¿Cómo puede ayudar el Strict Mode a depurar este problema?

El Strict Mode en desarrollo ejecuta un ciclo adicional de configuración y limpieza para los Effects. Si permanecen dos listeners o conexiones, la limpieza está incompleta o utiliza la identidad de callback incorrecta. Una vez que la limpieza es correcta, el ciclo adicional debería dejar una sola suscripción activa. La instantánea obsoleta en sí sigue la semántica normal de cierres de JavaScript en ambos modos.

Pregunta de seguimiento 7: ¿Qué pasa si se dispara un evento entre la limpieza y la nueva conexión?

Define la garantía de entrega del sistema externo. El cliente puede necesitar un cursor reanudable, un número de secuencia, una ventana de reproducción o una instantánea seguida de un flujo. Un cambio de dependencias en React puede gestionar el tiempo de vida local, pero no puede garantizar una entrega sin pérdidas a través de una reconexión con el servidor. Ese es un requisito del protocolo y debe probarse por separado.

Pregunta de seguimiento 8: ¿Cómo evitas que una solicitud fetch más antigua sobrescriba resultados más nuevos?

Aborta la solicitud obsoleta durante la limpieza del Effect cuando la API admita cancelación. De lo contrario, asigna una generación a cada solicitud y actualiza el estado solo cuando la generación que se completa siga siendo la actual. Una ref con el valor más reciente por sí sola no cancela el trabajo de red y no debe presentarse como una garantía de ordenamiento.

Fuentes públicas

Preguntas relacionadas