Inicio / Artículos / Compromisos de React Native a gran escala: estado, listas, dispositivos, tokens, actualizaciones

Compromisos de React Native a gran escala: estado, listas, dispositivos, tokens, actualizaciones

Cinco problemas de React Native que ponen a prueba el juicio técnico: estado del servidor frente al del cliente, FlatLists con retrasos, dispositivos Android de gama baja, almacenamiento de tokens y actualizaciones incrementales.

1719 palabras

Construir una interfaz en React Native es sencillo. La parte difícil surge a medida que la aplicación crece: el estado se dispersa por todas partes, las listas funcionan con retrasos, los teléfonos Android de bajo presupuesto se caen, los tokens se almacenan en texto plano y el framework queda años atrás. A continuación se presentan cinco situaciones de este tipo, frecuentemente utilizadas para poner a prueba a ingenieros senior en entrevistas, junto con los criterios que debe incluir una respuesta sólida para que puedas aplicarlos a tu propio código.

Separar el estado del servidor del estado del cliente

Cuando el estado local, las respuestas de la API, los cachés y las flags de interfaz compartidas se convierten en un verdadero lío, la primera pregunta no es “¿Redux o Context?”, sino “¿quién es el responsable de estos datos?”

Los datos provenientes de una API, como perfiles, feeds o listas de productos, constituyen el estado del servidor. Se vuelven obsoletos y deben ser recuperados nuevamente, almacenados en caché e invalidados. Al guardarlos en un almacén global se necesita reimplementar todo eso manualmente, además de gestionar las banderas de carga y errores. El fragmento a continuación muestra ese patrón antiestándar.

// ❌ Server data forced into a global store —
// now YOU own caching, staleness, invalidation, loading states…
dispatch(setProducts(await fetchProducts()));

TanStack Query y RTK Query existen precisamente para manejar esta capa. Aquí useQuery organiza los datos por categoría y los mantiene actualizados durante 60 segundos gracias a staleTime. La segunda parte del bloque (que en el código original está fusionada en una sola) muestra lo que queda para un almacén global: un pequeño objeto de sesión.

// ✅ Server state managed by a tool built for it
const { data, refetch } = useQuery({
  queryKey: ['products', category],
  queryFn: () => fetchProducts(category),
  staleTime: 60_000,
});// ✅ Truly shared client state — small and intentional
const useSession = create<SessionStore>((set) => ({
  user: null,
  setUser: (user) => set({ user }),
}));

El problema restante es mucho menor. Los campos de formulario, los controles de alternancia y los valores de animación permanecen dentro del componente, donde son poco costosos, están aislados y desaparecen junto con la pantalla. Un almacén global debería contener únicamente los datos que necesitan varias pantallas y que pertenecen al propio cliente, como por ejemplo el usuario conectado, el tema, las banderas de funcionalidad o el estado de la interfaz que abarca varias pantallas.

Qué falla cuando todo es global

Las actualizaciones vuelven a renderizar pantallas no relacionadas, cada funcionalidad está vinculada a la estructura del almacén, las pruebas deben simular el entorno y los refactores se convierten en tareas complejas. Un almacén sobredimensionado parece arquitectura, pero se comporta como deuda.

Diagnosticar un FlatList lento sin adivinar

Un FlatList parece lento aunque la API sea rápida. Usar React.memo en todas partes es una forma de adivinar; el enfoque disciplinado consiste en medir primero.

Usa React DevTools (o la biblioteca why-did-you-render) mientras se desplaza. Cuando todas las filas se vuelven a renderizar en cada movimiento de desplazamiento, la causa es la identidad referencial: funciones flecha inline en renderItem, objetos de estilo reconstruidos con cada renderizado, o la falta de keyExtractor, lo que obliga a un nuevo montaje. Aquí, cada renderizado crea un nuevo cierre onPress y un objeto style, por lo que ninguna fila puede omitir su renderizado.

// ❌ New function + new object on EVERY render → every row re-renders
<FlatList
  data={items}
  renderItem={({ item }) => (
    <Row item={item} onPress={() => open(item.id)} style={{ padding: 12 }} />
  )}
/>

La solución proporciona referencias estables a React: un Row memorizado, un renderItem envuelto en useCallback, una clave estable y getItemLayout para que la lista nunca mida las filas. Las propiedades tipadas convierten esto en TSX.

// ✅ Stable identities + memoized rows
const Row = React.memo(({ item, onPress }: RowProps) => { /* … */ });const renderItem = useCallback(
  ({ item }: ListRenderItemInfo<Item>) => <Row item={item} onPress={handlePress} />,
  [handlePress],
);<FlatList
  data={items}
  renderItem={renderItem}
  keyExtractor={(item) => item.id}
  getItemLayout={(_, index) => ({
    length: ROW_HEIGHT,
    offset: ROW_HEIGHT * index,
    index,
  })}
/>

getItemLayout solo es adecuado para filas de altura fija; valores incorrectos para alturas variables causan saltos y espacios en blanco.

Leer el monitor de rendimiento

Si las representaciones visuales se ven bien pero los fotogramas siguen disminuyendo, compare los dos contadores de Perf Monitor:

  • Un valor bajo en JS FPS indica que el hilo de JavaScript está sobrecargado, generalmente debido a una función renderItem intensiva o a un evento onScroll sin limitaciones.
  • Un valor bajo en UI FPS se debe a costos propios del sistema operativo, usualmente relacionados con imágenes. Decodificar fotos en alta resolución para convertirlas en miniaturas de 80 píxeles consume memoria y fotogramas; redimensione las imágenes en el servidor o utilice expo-image o FastImage con dimensiones adecuadas.

Solo entonces ajuste la lista con windowSize, removeClippedSubviews o cambiando a FlashList. Ajustar las propiedades de la lista antes de analizar los datos solo trata el síntoma.

Detección de caídas en dispositivos Android económicos

Una aplicación que funciona sin problemas en un teléfono de gama alta puede colapsar en los teléfonos Android económicos que poseen la mayoría de los usuarios. Comience con los datos: la Play Console o las herramientas de análisis muestran la verdadera combinación de dispositivos, que rara vez coincide con los teléfonos utilizados por su equipo.

Haga que el hardware de gama baja forme parte del trabajo diario: mantenga un dispositivo físico con 2 a 3 GB de RAM cerca, o utilice Firebase Test Lab con los modelos indicados por sus análisis, tal como lo hace esta herramienta.

gcloud firebase test android run \
  --app app-release.apk \
  --device model=a10,version=29 \
  --device model=redmi9,version=30   # the phones in your analytics, not yours

Siempre pruebe las versiones de lanzamiento. Las versiones de depuración ocultan el rendimiento real, y Hermes en las versiones de lanzamiento se comporta de manera diferente al entorno de depuración. Las fallas típicas en hardware débil son la presión de memoria causada por imágenes grandes o listas no eliminadas, un hilo principal sobrecargado, y cierres por falta de memoria que nunca ocurren en teléfonos de alta gama.

Funcionar de manera gradual en lugar de colapsar

Puedes adaptar la experiencia según el dispositivo. react-native-device-info informa sobre la memoria total.

import DeviceInfo from 'react-native-device-info';

Con él puedes marcar como de gama baja a los dispositivos con menos de 3 GB, servir miniaturas en lugar de imágenes completas, y omitir efectos como el desenfoque, el paralaje y las animaciones pesadas. Dado que getTotalMemory es asíncrono, calcula la marca una sola vez al iniciar en lugar de esperar durante el renderizado.

const totalMemory = await DeviceInfo.getTotalMemory();
const isLowEnd = totalMemory < 3 * 1024 ** 3; // < 3 GB RAM<Image
  source={{ uri: isLowEnd ? item.thumbUrl : item.fullResUrl }}
  // skip blur, parallax and heavy animations on low-end devices
/>

Luego, distribuye la aplicación de forma defensiva: segmenta los informes de Sentry o Crashlytics por nivel del dispositivo, utiliza lanzamientos escalonados en la Play Store (5%, 20%, 100%) y detente antes de que una versión defectuosa llegue a todos. El objetivo no es cero caídas, sino detectarlas temprano y a bajo costo.

Almacenar tokens de autenticación de forma segura

AsyncStorage escribe los datos sin cifrar en el disco: un archivo SQLite en Android y archivos normales de sandbox en iOS. Un dispositivo rooteado o con jailbreak, una copia de seguridad maliciosa o acceso al sistema de archivos exponen los tokens directamente. Está diseñado para preferencias, no para secretos.

Los tokens deben almacenarse en un sistema de almacenamiento respaldado por hardware, como el Keychain de iOS y Keystore de Android, que es lo que maneja react-native-keychain.

import * as Keychain from 'react-native-keychain';

Esta llamada almacena los tokens serializados con la opción WHEN_UNLOCKED_THIS_DEVICE_ONLY: solo son legibles mientras el dispositivo está desbloqueado y nunca se migran a otro dispositivo.

await Keychain.setGenericPassword('auth', JSON.stringify(tokens), {
  accessible: Keychain.ACCESSIBLE.WHEN_UNLOCKED_THIS_DEVICE_ONLY,
});

Flujo de actualización que soporta errores 401 concurrentes

Asigne a los tokens de acceso una vida útil de minutos en lugar de días, respáldelos con un token de actualización que se reemplace cada vez que se intercambie, y centralícelo en una capa de autenticación que lo actualice exactamente una vez, sin importar cuántas solicitudes fallen al mismo tiempo. Una promesa compartida hace que esto sea posible.

let refreshing: Promise<string> | null = null;

El interceptor de Axios vuelve a lanzar cualquier error que no sea 401. Para un código 401, ??= inicia una actualización solo si no hay ninguna en curso, de modo que los fallos concurrentes esperan a la misma promesa; finally la reinicia, y se vuelve a intentar la solicitud original con el nuevo token de acceso. El código original amontona varias instrucciones en una sola línea. Para un análisis más profundo, consulte por qué los códigos 401 concurrentes cierran la sesión de los usuarios y cómo solucionarlo con una actualización única.

api.interceptors.response.use(undefined, async (error) => {
  if (error.response?.status !== 401) throw error;  // Concurrent 401s all await the SAME refresh — no refresh storm
  refreshing ??= refreshTokens().finally(() => (refreshing = null));
  const newToken = await refreshing;  error.config.headers.Authorization = `Bearer ${newToken}`;
  return api.request(error.config); // retry the original request
});

Dos detalles más: no almacene tokens en el estado global accesible desde JavaScript por más tiempo del necesario, agregue fijación de certificados para APIs verdaderamente sensibles, y nunca registre tokens, ya que los informes de fallos capturan felizmente los encabezados de la solicitud. “Use SecureStore” es el nombre de una herramienta; una respuesta sólida abarca los modos de vida útil, actualización y fallo.

Actualizando una app de React Native de dos años

La app está desactualizada desde hace dos años, no se permite tiempo de inactividad y no hay presupuesto para reescribirla.

Nunca pase directamente a la versión más reciente. El React Native Upgrade Helper muestra la diferencia exacta entre versiones; avance una o dos versiones menores por paso, manteniendo la app compilable y listo para su distribución en todo momento. Cada paso representa un lanzamiento ordinario, lo cual es la forma de lograr cero tiempo de inactividad.

Revise primero las dependencias

Las actualizaciones fallan debido a bibliotecas nativas antiguas y sin mantenimiento, no por React Native en sí. Antes del primer paso, identifique las dependencias que impiden la nueva arquitectura o cumplir con los requisitos más recientes de Gradle y Xcode, y reemplace o cree versiones derivadas de aquellas abandonadas.

Automatice la verificación de cada paso

Configure pruebas básicas de extremo a extremo para los flujos críticos antes de comenzar, de modo que cada paso se verifique en minutos en lugar de mediante pruebas manuales de calidad. Este flujo de Maestro inicia sesión con un correo electrónico proveniente de una variable de entorno y verifica que las páginas de inicio, carrito de compras y pago sean accesibles.

# smoke-test.yaml — run with Maestro on every upgrade hop
appId: com.myapp
---
- launchApp
- tapOn: "Log in"
- inputText: ${EMAIL}
- tapOn: "Continue"
- assertVisible: "Home"
- tapOn: "Cart"
- assertVisible: "Checkout"

Presente el trabajo a la dirección como una forma de reducir riesgos, no como un refactoring: cada versión que se queda atrás aumenta el costo de la siguiente actualización forzada impuesta por las reglas de la tienda, la descontinuación de sistemas operativos y los parches de seguridad. Pequeños pasos convierten un proyecto intimidante en una serie de lanzamientos aburridos, y el objetivo es precisamente eso: aburrir.

Puntos clave

  • Determine quién es el propietario de cada dato; deja que una biblioteca de consultas gestione el estado del servidor y mantenga el almacén global reducido.
  • Haz un perfilamiento antes de optimizar: los problemas de identidad, el trabajo en hilos de JavaScript y la decodificación de imágenes requieren soluciones diferentes.
  • Prueba las versiones finales en los dispositivos reales de tus usuarios e implementa las actualizaciones por etapas.
  • Trata los tokens como un sistema: almacenamiento seguro, vida útil corta, rotación y actualización única por vuelo.
  • Realiza actualizaciones en pasos pequeños y listos para desplegar, respaldados por pruebas de funcionamiento automatizadas.