Skip to content

🎯 Meta: ya sabes React (cap. 17): componentes, props, estado, hooks. React Native reutiliza exactamente ese conocimiento para construir apps nativas de iOS y Android — el mismo lenguaje mental, un renderer distinto por debajo (vistas nativas, no DOM). Con Expo como capa de herramientas, pasar de "sé React" a "tengo una app en el móvil" es cuestión de horas, no semanas.

Versiones: React Native 0.86 · Expo SDK 57 · React 19.2 (la misma versión del cap. 17) · Expo Router (navegación por archivos, como Next.js cap. 18). New Architecture (Fabric + TurboModules) es el motor por defecto — no hay nada que activar.

📘 Requisitos: cap. 17 (React) completo. Este apéndice no repite hooks/estado — solo lo que cambia al salir del navegador.


U.1 · Qué cambia (y qué no) al dejar el navegador

React (cap. 17)                         React Native
┌────────────────────────┐             ┌────────────────────────┐
│ JSX, componentes, hooks  │  IGUAL →   │ JSX, componentes, hooks  │
│ useState, useEffect...   │  IGUAL →   │ useState, useEffect...   │
│ <div>, <p>, <button>     │  CAMBIA →  │ <View>, <Text>, <Pressable>│
│ CSS / Tailwind           │  CAMBIA →  │ StyleSheet (JS, no CSS)   │
│ DOM del navegador        │  CAMBIA →  │ Vistas nativas de iOS/Android│
│ react-router             │  CAMBIA →  │ Expo Router               │
└────────────────────────┘             └────────────────────────┘

🧠 No hay HTML ni CSS. No existe <div> ni background-color en un stylesheet .css. React Native traduce tus componentes a vistas nativas de verdad (UIView en iOS, android.view.View en Android) — por eso una app RN se siente nativa y no "una web metida en un marco" (a diferencia de los WebViews de hace una década).

Por qué es rápido: New Architecture y Hermes

Hasta hace pocas versiones, JS y nativo se hablaban por un bridge asíncrono serializando todo a JSON — un cuello de botella real en listas largas o animaciones. Desde React Native 0.76+, la New Architecture es el motor por defecto (ya no hay que activarla ni migrar):

Pieza viejaPieza nuevaQué gana
Bridge (async, JSON)JSI (JavaScript Interface)JS llama a nativo síncronamente, sin serializar
Renderer legadoFabricMenos saltos entre hilos al pintar la UI
Native Modules (todos cargan al arrancar)TurboModulesCarga perezosa: solo lo que usas, arranque más rápido
  • Hermes (el motor de JS de Meta, no V8/JSC) viene activado por defecto en Expo: arranque en frío más rápido y menor consumo de memoria que un motor JS de propósito general.
  • Animaciones fluidas de verdad: para gestos y animaciones complejas (swipe-to-delete, transiciones tipo Instagram) usa React Native Reanimated — corre la animación en el hilo de UI, no en el hilo de JS, así que no se traba aunque JS esté ocupado procesando datos.
  • FlatList/SectionList (U.4) ya se benefician de Fabric para virtualizar sin jank.

💡 Casi todo esto es invisible para ti: no escribes JSI ni TurboModules a mano salvo que construyas un módulo nativo propio. Lo relevante es que el 85% de las librerías populares ya son compatibles con New Architecture en 2026 — si una dependencia no lo es, es la primera sospechosa cuando algo rinde mal o crashea solo en producción.


U.2 · Proyecto nuevo con Expo

bash
npx create-expo-app@latest cantina-movil
cd cantina-movil
npx expo start          # abre un QR: escanéalo con la app Expo Go en tu móvil
cantina-movil/
├── app/                     ← Expo Router: cada archivo es una pantalla (como Next.js, cap. 18)
│   ├── _layout.tsx          ← layout raíz (como layout.tsx de Next)
│   ├── index.tsx            ← pantalla de "/"
│   └── pedido/
│       └── [id].tsx         ← ruta dinámica "/pedido/42"
├── components/
├── app.json                 ← config de la app (nombre, icono, permisos)
└── package.json

💡 Expo Go vs build nativo: Expo Go (la app que instalas de la store) te deja probar en segundos sin compilar nada — perfecto para desarrollar. Para publicar en las stores o usar módulos nativos que Expo Go no incluye, generas un build propio (U.8).


U.3 · Los componentes básicos (el HTML del móvil)

tsx
import { View, Text, Pressable, ScrollView, Image, TextInput } from 'react-native';

function TarjetaProducto({ nombre, precio, onComprar }: Props) {
  return (
    <View style={estilos.card}>                      {/* View ≈ div */}
      <Text style={estilos.titulo}>{nombre}</Text>    {/* Text ≈ p — TODO texto va en <Text> */}
      <Text style={estilos.precio}>{precio.toFixed(2)} €</Text>
      <Pressable style={estilos.boton} onPress={onComprar}>   {/* Pressable ≈ button */}
        <Text style={estilos.botonTexto}>Comprar</Text>
      </Pressable>
    </View>
  );
}

⚠️ Todo texto va dentro de <Text>. A diferencia del navegador, <View><Text>Hola</Text> {variable}</View> con texto suelto fuera de <Text> da error en runtime, no solo un warning. Es la primera sorpresa de todo el mundo viniendo de React web.

Estilos: StyleSheet, no CSS

tsx
import { StyleSheet } from 'react-native';

const estilos = StyleSheet.create({
  card: {
    padding: 16,
    borderRadius: 8,
    backgroundColor: '#161b22',
    // Flexbox es el ÚNICO sistema de layout — no hay Grid nativo, no hay float
    // y flexDirection por defecto es 'column' (al revés que en CSS web)
  },
  titulo: { fontSize: 16, fontWeight: '600', color: '#e8ecf1' },
  precio: { fontSize: 14, color: '#98a2b5', marginTop: 4 },
  boton: {
    marginTop: 12,
    backgroundColor: '#e0a63a',
    paddingVertical: 10,
    borderRadius: 6,
    alignItems: 'center',
  },
  botonTexto: { color: '#17130a', fontWeight: '700' },
});
CSS web (cap. 15.3)React Native
class="card"style={estilos.card} (objeto JS, no string)
display: flexFlexbox siempre activo, es el único layout
flex-direction: row por defecto en algunosflexDirection: 'column' por defecto
px, rem, %Números sin unidad = densidad de píxeles (no CSS px)
Media queriesuseWindowDimensions() + lógica condicional
Pseudo-clases :hoverNo existe — el móvil no tiene ratón; usa onPressIn/onPressOut

U.4 · Navegación con Expo Router (el "React Router" del móvil)

tsx
// app/_layout.tsx — layout raíz, envuelve TODA la app
import { Stack } from 'expo-router';

export default function Layout() {
  return <Stack screenOptions={{ headerStyle: { backgroundColor: '#161b22' } }} />;
}
tsx
// app/index.tsx → pantalla "/"
import { Link } from 'expo-router';
import { FlatList } from 'react-native';

export default function Carta() {
  const { data: productos } = useQuery({ queryKey: ['carta'], queryFn: listarProductos });

  return (
    <FlatList                                     // la lista optimizada del móvil (no <ul>/map a pelo)
      data={productos}
      keyExtractor={(p) => p.id}
      renderItem={({ item }) => (
        <Link href={`/pedido/${item.id}`}>        {/* navegación por archivos, como Next.js */}
          <TarjetaProducto {...item} />
        </Link>
      )}
    />
  );
}
tsx
// app/pedido/[id].tsx → ruta dinámica "/pedido/42"
import { useLocalSearchParams } from 'expo-router';

export default function DetallePedido() {
  const { id } = useLocalSearchParams<{ id: string }>();
  // ... igual que useParams() en React Router (cap. 17.10)
}

🧠 FlatList en vez de .map() directo: con cientos de items, .map() renderiza TODO de golpe; FlatList solo renderiza lo visible en pantalla (virtualización), imprescindible para rendimiento fluido en móvil. Es al móvil lo que el loading="lazy" de imágenes es a la web.


U.5 · Consumir tu API (literalmente el mismo código del cap. 17)

typescript
// api/productos.ts — ⚠️ IDÉNTICO al del cap. 17.9, cero cambios
export async function listarProductos(): Promise<Producto[]> {
  const res = await fetch(`${API_URL}/productos`);
  if (!res.ok) throw new Error(`HTTP ${res.status}`);
  return res.json();
}
tsx
// TanStack Query funciona exactamente igual que en la web (cap. 17.9)
const { data, isPending, error } = useQuery({ queryKey: ['productos'], queryFn: listarProductos });

💡 Esto es lo que hace valioso el apéndice: tu capa de datos (api/, hooks de React Query, lógica de negocio en el cliente) se comparte casi 100% entre web y móvil en un monorepo (ap. K) — solo cambian los componentes visuales (<div> vs <View>).

⚠️ localhost no funciona desde el móvil físico — apunta a la IP de tu máquina en la red local (http://192.168.1.X:3000) o usa un túnel (expo start --tunnel). El emulador de Android sí resuelve 10.0.2.2 como tu localhost.


U.6 · Auth en móvil (repaso del cap. 20, con un cambio clave)

En web usabas cookies httpOnly (cap. 20.3). En móvil no hay concepto de cookie del navegador — el patrón estándar es access + refresh token (cap. 20.4) guardados en almacenamiento seguro del dispositivo:

bash
npx expo install expo-secure-store
typescript
import * as SecureStore from 'expo-secure-store';

// SecureStore usa Keychain (iOS) / Keystore (Android) — cifrado a nivel de SO,
// el equivalente móvil de la cookie httpOnly: ni tu propio JS lo lee en claro
await SecureStore.setItemAsync('refreshToken', token);
const token = await SecureStore.getItemAsync('refreshToken');

⚠️ Nunca AsyncStorage para tokens. AsyncStorage es texto plano sin cifrar (el "localStorage" del móvil, con la misma advertencia del cap. 20.2). SecureStore es el equivalente correcto a una cookie httpOnly en este contexto.


U.7 · Funcionalidades nativas (lo que el navegador no te daba)

typescript
// Cámara
import { CameraView, useCameraPermissions } from 'expo-camera';
const [permiso, pedirPermiso] = useCameraPermissions();

// Ubicación
import * as Location from 'expo-location';
const { status } = await Location.requestForegroundPermissionsAsync();
const posicion = await Location.getCurrentPositionAsync();

// Notificaciones push (el equivalente nativo del Web Push del ap. N.6)
import * as Notifications from 'expo-notifications';
const { data: expoPushToken } = await Notifications.getExpoPushTokenAsync();
// envía expoPushToken a TU backend, que lo usa para mandar pushes vía la API de Expo

🧠 Compáralo con el apéndice N (PWA): una PWA accede a algunas de estas capacidades desde el navegador (cámara vía <input capture>, ubicación vía navigator.geolocation, push vía Web Push) sin pasar por una store. React Native te da acceso nativo completo (Bluetooth, HealthKit, background tasks…) a cambio de publicar en App Store/Play Store. La pregunta de PWA vs app nativa es la misma que HTMX vs SPA: empieza por lo más simple que resuelva el problema real.


U.8 · Build y publicación (EAS)

bash
npm install -g eas-cli
eas login
eas build:configure

eas build --platform android --profile preview     # .apk para probar sin la store
eas build --platform ios --profile production       # requiere cuenta Apple Developer
eas submit --platform android                        # sube directo a Google Play
json
// eas.json — perfiles de build (como los entornos de Terraform, ap. R)
{
  "build": {
    "preview": { "distribution": "internal" },
    "production": { "autoIncrement": true }
  }
}

💡 EAS Update permite publicar actualizaciones de JS sin pasar por la revisión de la store (para cambios que no tocan código nativo) — el equivalente móvil del deploy atómico del apéndice L, con la ventaja de que llega a usuarios que ya tienen la app instalada.

Variables de entorno por build (dev/preview/producción)

Igual que en L.2 las VITE_* se hornean en build-time, aquí necesitas que dev/preview/ production apunten a APIs distintas. app.json es estático; para lógica condicional usa app.config.ts:

typescript
// app.config.ts — sustituye a app.json cuando necesitas lógica
export default ({ config }) => ({
  ...config,
  extra: {
    apiUrl: process.env.APP_ENV === 'production'
      ? 'https://api.cantina.com'
      : process.env.APP_ENV === 'preview'
        ? 'https://api-staging.cantina.com'
        : 'http://192.168.1.50:3000',            // dev: la IP local del cap. U.5
  },
});
jsonc
// eas.json — cada perfil fija su APP_ENV antes del build (coherente con U.8)
{
  "build": {
    "preview": { "env": { "APP_ENV": "preview" } },
    "production": { "env": { "APP_ENV": "production" }, "autoIncrement": true }
  }
}
typescript
import Constants from 'expo-constants';
const API_URL = Constants.expoConfig?.extra?.apiUrl;   // resuelto en build-time, como VITE_*

⚠️ Igual que con VITE_* (ap. L): esto se fija al construir, no al arrancar. Un .apk de preview seguirá apuntando a staging para siempre, aunque cambies variables de entorno del dispositivo — si necesitas cambiarlo sin rebuild, resuélvelo con un endpoint de configuración remota, no con variables de entorno del sistema.


U.9 · Tests (el mismo enfoque del cap. 17.11)

bash
npm install -D jest jest-expo @testing-library/react-native
tsx
import { render, screen, fireEvent } from '@testing-library/react-native';
import { TarjetaProducto } from './TarjetaProducto';

test('llama a onComprar al pulsar el botón', () => {
  const onComprar = jest.fn();
  render(<TarjetaProducto nombre="Teclado" precio={89.9} onComprar={onComprar} />);

  fireEvent.press(screen.getByText('Comprar'));       // press, no click
  expect(onComprar).toHaveBeenCalled();
});

E2E real en simulador/dispositivo: Maestro o Detox — el equivalente móvil de Playwright (cap. 09).


U.10 · Buenas prácticas

  1. Comparte la capa de datos, no la UI. api/, hooks de TanStack Query, validación Zod (ap. K) — en un monorepo, cero duplicación. Los componentes visuales sí se reescriben.
  2. FlatList/SectionList para listas, nunca .map() a pelo con más de una pantalla de items.
  3. SecureStore para tokens, nunca AsyncStorage.
  4. Flexbox mental desde el día 1 — no hay Grid, no hay float, y column es el eje por defecto (al revés que en muchos casos de la web).
  5. Prueba primero si una PWA (ap. N) resuelve el problema antes de comprometerte a publicar en dos stores con sus propios ciclos de revisión.
  6. EAS Update para JS, build nativo solo cuando cambias módulos nativos — acelera iteración sin sacrificar la app instalada de tus usuarios.

✅ Ejercicio del apéndice

1. App Expo para la Cantina (cap. 22): carta con FlatList, detalle con Expo Router
   ([id].tsx), reutilizando literalmente tu api/ del cap. 17 si tienes monorepo (ap. K).
2. Login con el backend del cap. 20: access token en memoria, refresh en SecureStore.
3. Notificación push: cuando el pedido cambia de estado (SSE en web, cap. 21), el
   backend también dispara un push nativo con expo-notifications.
4. Cámara: escanea un código de mesa (QR) con expo-camera para asociar el pedido.
5. Build de preview con EAS para Android; instálalo en un dispositivo real (no solo Expo Go).
6. Tests: TarjetaProducto y el flujo de login con Testing Library.

🧠 Autoevaluación

Componentes, JSX, todos los hooks (useState, useEffect, custom hooks), el modelo de props down/eventos up, y librerías de datos como TanStack Query. Lo que cambia son los componentes de UI (View/Text en vez de div/p) y el sistema de estilos (StyleSheet en vez de CSS).

AsyncStorage guarda texto plano sin cifrar — el equivalente móvil de localStorage, con el mismo riesgo del cap. 20.2. SecureStore usa el almacenamiento cifrado del sistema operativo (Keychain/Keystore), el equivalente correcto a una cookie httpOnly en este contexto.

.map() renderiza los 500 componentes de golpe, incluidos los que no están en pantalla — consume memoria y CPU sin necesidad. FlatList virtualiza: solo monta lo visible más un margen, manteniendo la lista fluida sin importar el tamaño del dataset.

JSI permite llamadas síncronas entre JS y nativo sin serializar a JSON (el "bridge" antiguo); Fabric reduce los saltos entre hilos al pintar; TurboModules carga cada módulo nativo solo cuando se usa, acelerando el arranque en frío. Es el motor por defecto desde React Native 0.76+: lo aprovechas automáticamente salvo que una dependencia concreta aún no sea compatible.

Igual que las VITE_* del apéndice L, app.config.ts resuelve extra.apiUrl durante el build de EAS, no en tiempo de ejecución: el valor queda "horneado" en el binario. Cambiarlo sin un rebuild requiere un mecanismo de configuración remota, no variables de entorno del sistema operativo.


Volver al: README.md · Relacionado: 17-react.md, 20-autenticacion-fullstack.md, N-pwa-offline.md