🎯 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>nibackground-coloren un stylesheet.css. React Native traduce tus componentes a vistas nativas de verdad (UIViewen iOS,android.view.Viewen 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 vieja | Pieza nueva | Qué gana |
|---|---|---|
| Bridge (async, JSON) | JSI (JavaScript Interface) | JS llama a nativo síncronamente, sin serializar |
| Renderer legado | Fabric | Menos saltos entre hilos al pintar la UI |
| Native Modules (todos cargan al arrancar) | TurboModules | Carga 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
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óvilcantina-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)
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
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: flex | Flexbox siempre activo, es el único layout |
flex-direction: row por defecto en algunos | flexDirection: 'column' por defecto |
px, rem, % | Números sin unidad = densidad de píxeles (no CSS px) |
| Media queries | useWindowDimensions() + lógica condicional |
Pseudo-clases :hover | No existe — el móvil no tiene ratón; usa onPressIn/onPressOut |
U.4 · Navegación con Expo Router (el "React Router" del móvil)
// 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' } }} />;
}// 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>
)}
/>
);
}// 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)
}🧠
FlatListen vez de.map()directo: con cientos de items,.map()renderiza TODO de golpe;FlatListsolo renderiza lo visible en pantalla (virtualización), imprescindible para rendimiento fluido en móvil. Es al móvil lo que elloading="lazy"de imágenes es a la web.
U.5 · Consumir tu API (literalmente el mismo código del cap. 17)
// 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();
}// 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>).⚠️
localhostno 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í resuelve10.0.2.2como tulocalhost.
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:
npx expo install expo-secure-storeimport * 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
AsyncStoragepara tokens.AsyncStoragees texto plano sin cifrar (el "localStorage" del móvil, con la misma advertencia del cap. 20.2).SecureStorees el equivalente correcto a una cookiehttpOnlyen este contexto.
U.7 · Funcionalidades nativas (lo que el navegador no te daba)
// 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íanavigator.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)
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// 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:
// 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
},
});// 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 }
}
}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.apkde 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)
npm install -D jest jest-expo @testing-library/react-nativeimport { 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
- 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. FlatList/SectionListpara listas, nunca.map()a pelo con más de una pantalla de items.SecureStorepara tokens, nuncaAsyncStorage.- Flexbox mental desde el día 1 — no hay Grid, no hay float, y
columnes el eje por defecto (al revés que en muchos casos de la web). - 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.
- 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