* notes / react-native

React Native en producción

Lo que descubrí al llevar una app de React Native al App Store y a Play: Expo o bare, JSI, FlatList, OTA y los pequeños desencantos que nadie cuenta.

April 9, 20256 min read#react-native#expo#móvil#performance

Llegué a React Native con el optimismo del que cree que sabe React. A los tres días había entendido que compartir el lenguaje no implica compartir el modelo mental: lo que en la web resuelvo con un div y media docena de clases de Tailwind, en RN requiere pensar en hilos, en puentes, en colas de mensajes y en un sistema de layout que se parece a Flexbox sólo lo suficiente como para confundirme cuando no se comporta igual.

Contexto

Llevaba meses queriendo construir una aplicación móvil que acompañara mi blog: notificaciones push para suscriptores, lectura offline y un editor minimalista para borradores. Descarté las soluciones puramente web (PWA) por las limitaciones de iOS, y descarté escribir dos apps nativas porque mi presupuesto de tiempo no daba para mantener Swift y Kotlin en paralelo. React Native, con Expo encima, parecía la decisión menos mala.

La primera disyuntiva fue precisamente esa: Expo gestionado o bare workflow. La sabiduría popular dice que Expo es para prototipos y bare es para producción, pero esa dicotomía hace tiempo que dejó de ser cierta. El Expo Modules API, los config plugins y EAS Build han reducido la fricción del flujo gestionado hasta el punto de que la mayoría de las apps no necesitan jamás abrir Xcode. Opté por Expo y, salvo dos integraciones nativas concretas, no me arrepiento.

La arquitectura interna: del puente al JSI

Antes de tocar una sola línea conviene entender por dónde discurren los datos en una app de React Native. Durante años, el modelo se basó en un bridge asíncrono que serializaba mensajes JSON entre el hilo de JavaScript y el hilo nativo. Ese diseño tenía dos problemas: latencia y batching. La nueva arquitectura sustituye el puente por JSI (JavaScript Interface), una capa C++ que permite llamadas síncronas y referencias compartidas entre mundos, y sobre la que se apoyan Fabric (renderer) y TurboModules (módulos nativos).

Hilo JSReact, hooks, lógicaJSI (C++)Hostobjects, ref. compartidasTurboModulesAPIs nativas tipadasFabric RendererÁrbol de sombra C++Hilo UI nativoUIView / android.view.View

Lo que más me sorprendió al activar la nueva arquitectura no fue la velocidad bruta, sino la consistencia: las animaciones gestionadas desde JS dejan de tartamudear cuando el hilo se satura, porque muchas mediciones de layout pueden resolverse de manera síncrona sin esperar al siguiente tick.

La pila habitual sigue siendo react-navigation por inercia y robustez, aunque expo-router (basado en file-system routing) me convenció a la primera por su parecido con Next.js. Para el estado preferí Zustand frente a Redux: la API minimalista encaja con la naturaleza efímera de las pantallas. Para configuraciones de UI muy locales acabé recurriendo a Jotai, cuyos átomos derivados encajan particularmente bien con los selectors del editor de borradores.

El momento incómodo llegó cuando descubrí que la librería que necesitaba para leer códigos QR con la cámara frontal no exponía una opción en JavaScript. Tuve que escribir un módulo nativo con la API de Expo Modules, en Swift y Kotlin, y exponerlo como un hook. La curva no es trivial, pero el resultado se siente parte del mismo proyecto, sin acoplamientos extraños.

Implementación: una lista virtualizada que no tartamudea

FlatList es la herramienta que más he depurado. Cuando los elementos tienen altura conocida, getItemLayout evita que el componente mida cada fila y multiplica el rendimiento. Añadir keyExtractor estable, removeClippedSubviews en Android y windowSize ajustado completa la receta.

import { FlatList, View, Text, StyleSheet, Pressable } from "react-native";
import { useCallback } from "react";
import { useRouter } from "expo-router";
 
const ITEM_HEIGHT = 88;
 
type Post = { id: string; title: string; excerpt: string };
 
export function PostsScreen({ posts }: { posts: Post[] }) {
  const router = useRouter();
 
  const renderItem = useCallback(
    ({ item }: { item: Post }) => (
      <Pressable
        onPress={() => router.push(`/post/${item.id}`)}
        style={styles.row}
      >
        <Text style={styles.title} numberOfLines={1}>{item.title}</Text>
        <Text style={styles.excerpt} numberOfLines={2}>{item.excerpt}</Text>
      </Pressable>
    ),
    [router],
  );
 
  return (
    <FlatList
      data={posts}
      keyExtractor={(p) => p.id}
      renderItem={renderItem}
      getItemLayout={(_, index) => ({
        length: ITEM_HEIGHT,
        offset: ITEM_HEIGHT * index,
        index,
      })}
      initialNumToRender={10}
      windowSize={7}
      removeClippedSubviews
    />
  );
}
 
const styles = StyleSheet.create({
  row: { height: ITEM_HEIGHT, paddingHorizontal: 16, justifyContent: "center" },
  title: { fontSize: 16, fontWeight: "600" },
  excerpt: { fontSize: 13, opacity: 0.7, marginTop: 4 },
});

Un detalle que me costó entender: numColumns y getItemLayout se llevan mal si las alturas dependen de orientación. Para listas verdaderamente heterogéneas conviene migrar a FlashList de Shopify, que mantiene un recycler parecido al de Android.

OTA con EAS Update

Una de las grandes ventajas de RN frente a nativo es la posibilidad de publicar actualizaciones del bundle JavaScript sin pasar por las tiendas. EAS Update gestiona canales (production, staging) y permite empujar correcciones en minutos. La regla que me autoimpuse fue clara: el OTA es para arreglar lo que no debería haber roto, no para añadir funcionalidad que requiere permisos nuevos. La política de Apple es estricta y conviene respetarla.

Hallazgos

  • El hilo de JavaScript sigue siendo el cuello de botella más frecuente; mover trabajo pesado a workers nativos o a expo-task-manager libera fluidez perceptible.
  • Keyboard.dismiss() no resuelve todos los casos: KeyboardAvoidingView con behavior="padding" en iOS y "height" en Android es la combinación que más me ha funcionado.
  • Hermes encendido por defecto rebajó el tiempo de arranque en frío de 1.8 s a 0.9 s sobre un iPhone 12.
  • Las imágenes locales pesan más de lo que aparenta el bundle; pasé a expo-image con caché en disco y la app dejó de inflar memoria al desplazarse.
  • Probar en simulador es engañoso: la primera vez que ejecuté la lista en un Android de gama media, los Pressable parpadeaban porque el ripple nativo se solapaba con mi animación.

Closure

React Native no es una bala de plata, pero permite reutilizar buena parte del conocimiento de React y entregar una app coherente en dos plataformas con un equipo pequeño. La nueva arquitectura, lejos de ser un experimento, ya está madura: lo notable es lo poco que tuve que cambiar para activarla. Si alguien me pregunta si volvería a elegirla para un proyecto similar, mi respuesta es un sí matizado: sí, sabiendo que los detalles nativos terminarán reclamando atención y que el día que algo se rompe en Android, no hay atajos a leer el logcat.