Inicio / Artículos / Dependencias inestables de useEffect: diagnosticando el agotamiento de la batería en React Native

Dependencias inestables de useEffect: diagnosticando el agotamiento de la batería en React Native

Vea cómo una dependencia de objeto y un array de dependencias faltantes causaron consumo excesivo de batería y problemas en el rendimiento del mapa en React Native, cómo hacer su perfilamiento y las tres soluciones que funcionaron.

2423 palabras

Una pantalla en tiempo real que parece perfecta en el simulador puede seguir incluyendo un error que hace que los teléfonos se sobrecalienten y las animaciones se interrumpan, todo sin registrar ni un solo error. El culpable habitual es un useEffect cuyas dependencias cambian con mucha más frecuencia que los datos de los que se ocupa. Este estudio de caso analiza dicho error en una pantalla de seguimiento de entregas en tiempo real: por qué existe useEffect, cuándo es la herramienta adecuada, cómo dos pequeños errores en las dependencias se combinaron para crear un bucle de renderizado, cómo se localizó el problema con herramientas de análisis tanto en el lado JavaScript como en el nativo, y los tres cambios que lo solucionaron.

Por qué existe useEffect

Antes de React 16.8, los componentes basados en clases distribuían efectos secundarios como llamadas a API, suscripciones y temporizadores en tres métodos de ciclo de vida separados: componentDidMount, componentDidUpdate y componentWillUnmount. Una preocupación lógica, por ejemplo “escuchar este socket mientras la pantalla está visible”, solía dividirse entre estos tres métodos, lo que dispersaba el código relacionado y hacía fácil olvidar algún paso.

Los hooks llegaron con React 16.8, y useEffect fue diseñado para unificar esa lógica. En lugar de pensar en las etapas del ciclo de vida, se describe cómo el componente mantiene la sincronización con un sistema externo, ya sea una solicitud, un receptor nativo, una suscripción, un temporizador o un fotograma de animación. El efecto se ejecuta después del renderizado, puede devolver una función de limpieza y vuelve a ejecutarse cada vez que cambia un valor en su array de dependencias:

useEffect(() => {
  // side effect code
return () => {
    // cleanup code
  };
}, [dependencies]);

En React Native, los hooks aparecen constantemente, ya que casi todo el trabajo fuera de la renderización se considera un efecto secundario: los listeners de AppState y NetInfo, los eventos de Keyboard, los monitores de ubicación, las conexiones WebSocket y los SDKs nativos para funciones como la cámara o Bluetooth.

Por qué vale la pena mantener esta disciplina

Los efectos secundarios necesitan un lugar donde ejecutarse una vez que se haya completado la renderización, y otro lugar donde desactivarse antes de que el componente sea desmontado o antes de que el efecto se ejecute nuevamente. Sin esa estructura, se terminan teniendo listeners no eliminados, suscripciones duplicadas y cierres obsoletos, y esos problemas cuestan mucho más que el uso correcto de useEffect.

Cuándo usar useEffect y cuándo no

Algunos usos adecuados en una aplicación React Native incluyen:

  • escuchar fuentes de eventos nativas como AppState, NetInfo, Keyboard o Dimensions
  • ejecutar temporizadores, intervalos o bucles de animación solo mientras una pantalla esté visible
  • sincronizar el estado local con una propiedad o almacén global una vez finalizada la renderización
  • cargar datos al inicializar la aplicación, o nuevamente cuando cambie un dato de entrada como el ID del usuario actual
  • controlar un módulo nativo de forma imperativa, por ejemplo activando o desactivando el seguimiento de ubicación, los escaneos BLE o la transmisión de cámara

Situaciones en las que un efecto no es la herramienta adecuada:

  • Obtener un valor a partir de propiedades o estado. Calcúlalo durante la renderización en su lugar.
  • Reaccionar a una acción del usuario como un clic en un botón. Manejalo en el manejador de eventos, no en un efecto que vigile cambios de estado.
  • "Esperando" que llegue una actualización de estado. Esto generalmente significa que se deben fusionar dos valores de estado separados.
  • Omitir el array de dependencias, o depender de un objeto o array que se recrea en cada renderizado. Esta es exactamente la trampa descrita en el resto de este artículo.
  • Para un análisis más amplio del antipatrón de sincronización, consulte nuestro artículo sobre por qué sincronizar el estado con useEffect es arriesgado.

    El error: una pantalla de seguimiento que agotó la batería

    Imagínese una pantalla en tiempo real para el seguimiento de entregas: un mapa con la posición del conductor que se actualiza en tiempo real, similar a una aplicación de entrega de comida. Funcionó en el simulador, pasó las pruebas de calidad en dos o tres dispositivos y se lanzó al mercado. Unas dos semanas después, comenzaron a llegar tickets de soporte:

    • En Android, los usuarios indicaron que el teléfono se calentaba y que la batería disminuía aproximadamente un 15% en 20 minutos de mantener la pantalla de seguimiento activa.
    • En iOS, los usuarios dijeron que el mapa funcionaba con interrupciones: la marca del conductor saltaba de una posición a otra en lugar de moverse suavemente, y el desplazamiento era lento.

    Resultó que dos síntomas diferentes compartían una misma causa raíz.

    El componente, simplificado

    Aquí hay una versión reducida de la pantalla. Mantiene en estado la ubicación del conductor y el pedido, establece una conexión de socket, se suscribe a las actualizaciones de ubicación en un efecto y vuelve a calcular el tiempo estimado de llegada en otro. Las dos líneas resaltadas son donde ocurrieron los problemas:

    function TrackingScreen({ orderId }) {
      const [driverLocation, setDriverLocation] = useState(null);
      const [order, setOrder] = useState(fetchOrderSync(orderId)); // returns a new object reference
      const socket = useMemo(() => connectSocket(), []); // looked memoized, wasn't the issue
    
      useEffect(() => {
        const subscription = LocationSocket.on('update', (loc) => {
          setDriverLocation(loc);
        });
        return () => subscription.remove();
      }, [order]); // 🚩 the bug
      useEffect(() => {
        console.log('Recalculating ETA...');
        calculateETA(order, driverLocation);
      }); // 🚩 no dependency array at all
      return <Map driverLocation={driverLocation} order={order} />;
    }
    

    Se acumularon dos problemas independientes uno encima del otro.

    1. order recibía una identidad de objeto nueva cada vez que se renderizaba el padre. En la aplicación real, esa identidad provenía de un gancho más arriba en la estructura que distribuía las propiedades en un objeto nuevo en cada ocasión. (El fragmento simplificado muestra que proviene de useState, el cual en realidad mantiene una referencia estable; considere esa línea como un sustituto del gancho superior.) Dado que el efecto de suscripción listaba order como dependencia, React ejecutaba la limpieza y volvía a suscribirse al socket de ubicación después de cada render, no solo cuando el orden cambiaba realmente.
  • El efecto ETA no tenía ningún array de dependencias en absoluto. Un efecto sin uno se ejecuta después de cada renderizado, incluidos los generados por setDriverLocation dentro del primer efecto. En esta aplicación, el cálculo de la ETA también causaba una actualización de estado en otra parte, lo que cerraba el bucle: actualización de ubicación, nuevo renderizado, efecto ETA, otra actualización de estado, otro renderizado, y así sucesivamente.
  • El mismo código producía síntomas diferentes según la plataforma. En Android, el socket se desconectaba y volvía a conectarse de forma rápida, lo que mantenía la radio y la CPU ocupadas casi continuamente; esa era la verdadera causa del agotamiento de la batería. En iOS, la actividad de la radio se regulaba de manera distinta, pero el ciclo constante de suscripción y renderizado seguía sobrecargando el hilo de JavaScript e hizo que el mapa volviera a cargar su capa de marcadores con mucha más frecuencia de la necesaria, lo que los usuarios percibían como interrupciones.

    Una nota adicional sobre el fragmento: useState(fetchOrderSync(orderId)) llama a fetchOrderSync en cada renderizado, aunque React solo utiliza el resultado la primera vez. Si el valor inicial es costoso, pase una función en su lugar, como en useState(() => fetchOrderSync(orderId)), para que se ejecute solo una vez.

    Cómo se diagnosticó el problema

    Paso 1: Confirmar que son re-renderizaciones, no el library de mapas

    El primer sospechoso natural fue el SDK de mapas. El React DevTools Profiler, que se conecta a una aplicación React Native a través de la misma conexión Metro utilizada para el desarrollo, descartó rápidamente esa posibilidad. El equipo capturó un perfil de 10 segundos mientras se mostraba la pantalla de seguimiento sin ninguna interacción.

    La grabación mostró que el árbol de componentes realizaba decenas de renderizaciones por segundo, mientras que el backend solo enviaba una nueva ubicación del controlador aproximadamente cada 3 a 5 segundos. Esa discrepancia fue la primera pista real. Como regla general, la frecuencia de renderización debería seguir los cambios significativos en los datos; cuando las renderizaciones superan con creces las actualizaciones, algo está provocándolas artificialmente.

    Paso 2: Descubrir por qué se vuelven a renderizar

    La vista ordenada del Profilador mostró que TrackingScreen y Map se actualizaban uno justo después del otro, una y otra vez. Para ver el desencadenante exacto, se añadió temporalmente la pequeña biblioteca de depuración why-did-you-render. Esta registra qué cambio en propiedades o estado causó cada renderización, y reportó lo siguiente:

    TrackingScreen re-rendered because of changed props: order
    order: Object !== Object (deep equal: true)
    

    La parte “deep equal: true” fue la prueba decisiva. El contenido de order no había cambiado de manera significativa; solo su referencia sí, ya que el objeto se reconstruía en la etapa anterior con cada iteración. React compara las dependencias mediante Object.is, por lo que un objeto estructuralmente idéntico pero recién creado siempre se considera un cambio.

    Paso 3: Observar el lado nativo

    El perfilado de JavaScript explica los procesos de renderizado, pero no qué está haciendo el hardware de red del dispositivo. En el lado nativo, se utilizó Flipper con su plugin Network y un plugin de registro personalizado para observar el ciclo de vida de WebSocket. Los registros mostraron eventos connect y close repetidos con solo segundos de diferencia, en lugar de una conexión estable mientras la pantalla estuviera abierta. Eso confirmó que el socket se desmontaba cada vez que se ejecutaba nuevamente el efecto.

    El depurador Hermes de Flipper añadió una confirmación más: un punto de interrupción colocado en la función de limpieza del efecto de suscripción se activaba con mucha más frecuencia de la que podría explicarse por una desmontaje real o un cambio en el pedido.

    Una advertencia relacionada con el tiempo: las versiones más recientes de React Native han dejado de usar Flipper como herramienta de depuración por defecto en favor de React Native DevTools, por lo que consulte la documentación actual de React Native para conocer la configuración recomendada para su versión. El enfoque, que consiste en observar el ciclo de vida de la conexión y interrumpir las operaciones dentro de las funciones de limpieza, es aplicable independientemente de las herramientas que utilice.

    Paso 4: Medir el impacto real en la batería y la CPU

    Finalmente, el Profiler de Android Studio, utilizando sus vistas CPU y Energía junto con Flipper, cuantificó los daños causados:

    • Antes de la corrección: un uso sostenido del CPU del 35 al 40% mientras la pantalla de seguimiento permanecía inactiva, y el analizador de energía clasificó la aplicación como una consumidora alta de batería debido a la actividad de radio constante.
    • Después de la corrección: el uso del CPU en estado inactivo disminuyó a aproximadamente el 4 al 6%, y el analizador de energía ya no registró uso constante de radio. Mostraba ráfagas cortas y periódicas que coincidían con el verdadero intervalo de actualización.

    La corrección: tres cambios específicos

    Cada cambio aborda un eslabón en la cadena.

    1. Depender de una primitiva en lugar de un objeto

    La suscripción solo necesita reiniciarse cuando cambia el propio pedido, y la cadena orderId identifica eso con precisión. Dado que las primitivas se comparan por valor, este permanece igual en todas las renderizaciones:

    useEffect(() => {
      const subscription = LocationSocket.on('update', (loc) => {
        setDriverLocation(loc);
      });
      return () => subscription.remove();
    }, [orderId]); // orderId is a primitive string — stable across re-renders
    

    2. Declarar dependencias para cada efecto

    Omite el array solo cuando realmente quieras que el efecto se ejecute después de cada renderizado, lo cual es raro. Aquí, la ETA debería recalcularse cuando cambie la ubicación del conductor:

    useEffect(() => {
      calculateETA(order, driverLocation);
    }, [driverLocation]); // only recalculate when location actually changes
    

    Estrictamente hablando, el efecto también lee order, por lo que la regla de linting react-hooks/exhaustive-deps lo solicitará en el array. Una vez que order esté memorizado (en la próxima corrección), añadirlo es seguro y mantiene el efecto fiable, ya que solo se volverá a ejecutar cuando realmente cambie el orden. Omitir un valor que el efecto lee conlleva el riesgo de calcular la ETA con datos obsoletos.

    3. Memoriza el objeto order en la fase inicial

    Finalmente, estabiliza el objeto donde se crea, de modo que su referencia solo cambie cuando cambien los campos relevantes:

    const order = useMemo(() => buildOrder(rawOrderData), [rawOrderData.id, rawOrderData.status]);
    

    Tenga cuidado con esa lista de dependencias: al contener solo id y status, cualquier cambio en otro campo de rawOrderData, como la dirección de entrega, no generará un nuevo order. Esto solo es correcto si nada posterior depende de esos otros campos.

    Con los tres cambios implementados, el socket se conectaba una vez por visita a la pantalla, el tiempo estimado de entrega se volvía a calcular solo cuando la ubicación realmente cambiaba, y la tasa de renderizado pasó de docenas por segundo a aproximadamente uno cada pocos segundos, acorde con el flujo real de datos.

    Lecciones para los equipos de React Native

    • Suponga que las dependencias de objetos y arrays son inestables. A menos que los haya creado con useMemo o useCallback, espere obtener una nueva referencia en cada renderizado. Prefiera dependencias primitivas como los IDs cuando estos expresen la intención deseada.
    • Siempre escriba el array de dependencias y no ignore la regla de linting. react-hooks/exhaustive-deps existe precisamente para detectar este tipo de errores. Desactivarlo sin comprender la advertencia es cómo estos problemas llegan a la producción; en su lugar, resuelva la inestabilidad.
    • Analice el rendimiento de las pantallas inactivas, no solo de las interacciones. Este error apareció únicamente cuando nadie tocaba la pantalla, que es exactamente el estado que los pruebas manuales tienden a pasar por alto.
  • La disminución de la batería y las interrupciones en el rendimiento pueden ser el mismo problema. El comportamiento de la radio y la CPU en Android convirtió esto en un problema relacionado con la batería, mientras que el pipeline de renderizado de iOS transformó la misma causa raíz en interrupciones en el rendimiento.
  • Analice ambas partes de la aplicación. Un perfilador de JavaScript muestra los procesos de renderizado y sus causas; las herramientas nativas, en cambio, muestran las conexiones, el uso de la radio y el consumo energético. Un problema como este suele requerir un análisis detallado de ambos aspectos para poder diagnosticarlo con certeza.
  • Si desea profundizar en el aspecto del renderizado, nuestro resumen sobre patrones comunes que provocan renders innecesarios en React aborda trampas relacionadas.

    Conclusión

    Pocos hooks son tan fáciles de escribir como useEffect, y pocos también son tan propensos a cometer errores de forma silenciosa. En pantallas en tiempo real, un error en las dependencias no solo genera una línea adicional en el registro: puede mantener activo al procesador, sobrecargar el hilo de JavaScript y hacer que la aplicación parezca defectuosa sin mostrar ningún error visible. Utilizar dependencias estables, arrays explícitos y realizar un análisis de los estados inactivos son hábitos sencillos que evitan la versión más grave de este problema.

    Lecturas relacionadas

  • Crear un lector de PDF resistente a errores para documentos grandes en React Native — Aprenda una arquitectura para renderizar PDFs masivos de miles de páginas en React Native, con árboles de marcadores perezosos, búsqueda y navegación estable en dispositivos de bajo rendimiento.
  • Flutter vs React Native: Las decisiones que solo se manifiestan en producción — Una comparación realista de Flutter y React Native que omite las disputas por pruebas de rendimiento y se centra en el renderizado, el lenguaje, el estado, las listas y el ecosistema a medida que la aplicación crece.
  • Planificando la actualización a Expo SDK 58: iOS 27, React Native 0.88 y nuevas herramientas — Un recorrido práctico por la versión beta de Expo SDK 58: qué cambios hay en iOS 27 y React Native 0.88, qué funciones son experimentales y cómo probar la actualización de forma segura.
  • Elegir la herramienta React adecuada: Derive, Handle, Fetch, Defer o Effect — Una guía de decisión para reemplazar las llamadas reflexivas a useEffect por valores derivados, controladores de eventos, una capa de datos, useTransition, useMemo medido y las API de React 19.
  • Levantando informes de rendimiento de React en Chrome DevTools para identificar renderizaciones lentas — Aprenda cómo los informes de Scheduler, Components y Server añadidos en React 19.2 muestran dónde pasa su tiempo una interacción lenta, y un flujo de trabajo de registrar-problema-resolver para actuar al respecto.