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.
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,KeyboardoDimensions - 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.
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.
orderrecibí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 deuseState, 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 listabaordercomo 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.
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
useMemoouseCallback, 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-depsexiste 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.
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
- Fugas de memoria en React Native: Rastreo del heap de JS y de los propietarios de la memoria nativa — Aprenda por qué la recolección de basura no puede salvar a una aplicación React Native de las fugas en el código nativo, y cómo descubrir qué mantiene activos los callbacks, los objetos JSI y las imágenes decodificadas.