¿Qué hace Expo?
Patrones prácticos para construir aplicaciones React Native de producción con Expo. Cubre navegación, estado, obtención de datos, listas, estilos y APIs nativas. Se complementa con el conjunto de reglas rules/react-native/: las reglas dicen qué hacer cumplir, este skill muestra cómo.
Las bibliotecas mencionadas a continuación (NativeWind, Zustand/Jotai, TanStack Query) son opciones comunes y bien establecidas que se muestran como ilustración — los patrones importan más que el paquete específico, y cualquier equivalente funciona. Se utiliza Zod para la validación para mantener la coherencia con las reglas typescript/ existentes de ECC.
Estos patrones asumen el flujo de trabajo gestionado de Expo (Expo Router, EAS, módulos expo-*) en la Nueva Arquitectura (la opción predeterminada en los SDK de Expo recientes, obligatorio a partir del SDK 55+). NO asumen el DOM del navegador — React Native no tiene <div>, ni barra de URL, ni valores predeterminados de obtención de datos web.
Cuándo activarlo
Usa este skill cuando:
- Construyas o edites pantallas, componentes o navegación de React Native / Expo
- Configures el enrutamiento con Expo Router (directorio
app/basado en archivos) - Decidas dónde pertenece el estado (caché del servidor vs. store del cliente vs. parámetros de ruta vs. formulario)
- Conectes la obtención de datos con TanStack Query y valides respuestas con Zod
- Renderices listas largas o pesadas
- Elijas o apliques un enfoque de estilos (NativeWind o StyleSheet)
- Accedas a APIs nativas del dispositivo (cámara, ubicación, notificaciones) o almacenamiento seguro
- Revises código RN para problemas específicos de dispositivos móviles
NO uses los patrones web/React-DOM aquí — URL-como-estado, <div> y SWR-para-navegador no se aplican a React Native.
Conceptos básicos
Estructura del proyecto (Expo Router)
Enrutamiento basado en archivos bajo app/. Mantén los archivos de ruta livianos: leen y validan parámetros, luego delegan a un componente de pantalla que vive en components/ o features/.
app/
_layout.tsx # stack raíz
(tabs)/
_layout.tsx # navegador de pestañas
index.tsx # Inicio
user/[id].tsx # ruta dinámica
components/
features/
user/UserProfile.tsx
Navegación: validar parámetros de ruta
Los enlaces profundos y las rutas dinámicas entregan cadenas no confiables. Valídalas con Zod antes de usarlas.
// app/user/[id].tsx
import { useLocalSearchParams, router } from 'expo-router'
import { z } from 'zod'
import { UserProfile } from '@/features/user/UserProfile'
const Params = z.object({ id: z.string().uuid() })
export default function UserRoute() {
const parsed = Params.safeParse(useLocalSearchParams())
if (!parsed.success) {
router.replace('/not-found')
return null
}
return <UserProfile userId={parsed.data.id} />
}
Estado: mantener las responsabilidades separadas
No dupliques datos del servidor en un store del cliente. Cada responsabilidad tiene su propio hogar.
| Responsabilidad | Opciones comunes |
|---|---|
| Estado del servidor (datos remotos) | una biblioteca de caché del servidor (TanStack Query, SWR) |
| Estado del cliente/UI | un store liviano (Zustand, Jotai) o Context |
| Estado de ruta/navegación | parámetros de Expo Router |
| Estado de formulario | una biblioteca de formularios (ej. React Hook Form) + validación de esquema |
| Secretos / tokens | expo-secure-store |
| Persistencia no secreta | AsyncStorage / MMKV |
Prefiere useState local hasta que el estado realmente necesite ser compartido.
Obtención de datos: una biblioteca de caché + Zod
Usa una biblioteca de caché del servidor (TanStack Query, SWR) en lugar de fetch-en-useEffect. Valida en el límite e infiere tipos del esquema. Maneja los estados de carga, error y vacío explícitamente. (El ejemplo usa TanStack Query).
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { z } from 'zod'
const User = z.object({ id: z.string(), email: z.string().email() })
type User = z.infer<typeof User>
export function useUser(id: string) {
return useQuery({
queryKey: ['user', id],
queryFn: async (): Promise<User> => User.parse(await api.getUser(id)),
})
}
export function useUpdateEmail(id: string) {
const qc = useQueryClient()
return useMutation({
mutationFn: (email: string) => api.updateEmail(id, email),
onSuccess: () => qc.invalidateQueries({ queryKey: ['user', id] }),
})
}
Listas: virtualizar, nunca mapear un array grande en un ScrollView
import { FlatList } from 'react-native'
<FlatList
data={items}
keyExtractor={(item) => item.id}
renderItem={renderItem} // memorizado
initialNumToRender={10}
windowSize={5}
/>
Usa FlashList (Shopify) para listas grandes o heterogéneas.
Estilos: elegir un sistema
StyleSheet.create() es la opción nativa del framework; las bibliotecas de clases de utilidad (ej. NativeWind) son una alternativa común. Elige uno y mantén la coherencia. Nunca construyas objetos de estilo en línea en JSX en rutas críticas.
// NativeWind
<View className="p-4 rounded-2xl bg-white">
<Text className="text-base font-semibold">Hola</Text>
</View>
// StyleSheet
const styles = StyleSheet.create({ card: { padding: 16, borderRadius: 16, backgroundColor: '#fff' } })
<View style={styles.card}>...</View>
APIs nativas: envolver en hooks, limpiar efectos
Mantén las llamadas al SDK de Expo y las suscripciones dentro de hooks use*, no en JSX. Siempre limpia.
import { useEffect, useState } from 'react'
import * as Location from 'expo-location'
type LocationState =
| { status: 'loading' }
| { status: 'denied' }
| { status: 'granted'; coords: Location.LocationObjectCoords }
export function useCurrentLocation() {
// Rastrea el estado, no solo las coordenadas — para que la UI pueda distinguir "todavía cargando"
// de "permiso denegado" y mostrar un mensaje procesable.
const [state, setState] = useState<LocationState>({ status: 'loading' })
useEffect(() => {
let active = true
;(async () => {
const { status } = await Location.requestForegroundPermissionsAsync()
if (status !== 'granted') {
if (active) setState({ status: 'denied' })
return
}
const pos = await Location.getCurrentPositionAsync({})
if (active) setState({ status: 'granted', coords: pos.coords })
})()
return () => { active = false } // ignorar resultado obsoleto después de desmontar
}, [])
return state
}
Almacenamiento seguro para tokens
import * as SecureStore from 'expo-secure-store'
await SecureStore.setItemAsync('auth_token', token) // Keychain / Keystore
const token = await SecureStore.getItemAsync('auth_token')
Ejemplos de código
Una pantalla completa: ruta → consulta → lista → estados
// app/(tabs)/orders.tsx
import { memo, useCallback } from 'react'
import { FlatList, Text, View } from 'react-native'
import { useQuery } from '@tanstack/react-query'
import { z } from 'zod'
const OrderSchema = z.object({ id: z.string(), total: z.number(), status: z.string() })
const OrdersSchema = z.array(OrderSchema)
type Order = z.infer<typeof OrderSchema>
function useOrders() {
return useQuery({
queryKey: ['orders'],
queryFn: async () => OrdersSchema.parse(await api.listOrders()),
})
}
// Memorizado para que su referencia sea estable entre renderizados (ver la guía de listas).
const OrderRow = memo(function OrderRow({ item }: { item: Order }) {
return (
<View className="px-4 py-3 border-b border-neutral-200">
<Text className="font-medium">#{item.id}</Text>
<Text className="text-neutral-500">{item.status} · ${item.total}</Text>
</View>
)
})
export default function OrdersScreen() {
const { data, isLoading, isError, refetch, isRefetching } = useOrders()
const renderItem = useCallback(({ item }: { item: Order }) => <OrderRow item={item} />, [])
if (isLoading) return <Centered><Text>Cargando…</Text></Centered>
if (isError) return <Centered><Text accessibilityRole="alert">No se pudieron cargar los pedidos.</Text></Centered>
if (!data?.length) return <Centered><Text>Aún no hay pedidos.</Text></Centered>
return (
<FlatList
data={data}
keyExtractor={(o) => o.id}
onRefresh={refetch}
refreshing={isRefetching}
renderItem={renderItem}
/>
)
}
Un formulario: React Hook Form + resolución Zod
import { useForm, Controller } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { z } from 'zod'
import { TextInput, Button, Text } from 'react-native'
const Schema = z.object({ email: z.string().email('Correo electrónico inválido') })
type FormValues = z.infer<typeof Schema>
export function EmailForm({ onSubmit }: { onSubmit: (v: FormValues) => void }) {
const { control, handleSubmit, formState: { errors } } = useForm<FormValues>({
resolver: zodResolver(Schema),
defaultValues: { email: '' },
})
return (
<>
<Controller
control={control}
name="email"
render={({ field: { value, onChange, onBlur } }) => (
<TextInput
value={value}
onChangeText={onChange}
onBlur={onBlur}
autoCapitalize="none"
keyboardType="email-address"
accessibilityLabel="Dirección de correo electrónico"
/>
)}
/>
{errors.email && <Text accessibilityRole="alert">{errors.email.message}</Text>}
<Button title="Guardar" onPress={handleSubmit(onSubmit)} />
</>
)
}
Antipatrones
// MAL: array grande mapeado dentro de un ScrollView (sin virtualización, entrecortado, alto consumo de memoria)
<ScrollView>{items.map((i) => <Row key={i.id} item={i} />)}</ScrollView>
// BIEN: FlatList / FlashList
// MAL: datos del servidor copiados en un store del cliente (dos fuentes de verdad, datos obsoletos)
const useStore = create((set) => ({ users: [], setUsers: (u) => set({ users: u }) }))
useEffect(() => { getUsers().then(setUsers) }, [])
// BIEN: useQuery posee el estado del servidor; deriva lo que necesites
// MAL: tokens en AsyncStorage (no encriptados)
await AsyncStorage.setItem('auth_token', token)
// BIEN: expo-secure-store
// MAL: confiar en parámetros de enlaces profundos sin validar
const { id } = useLocalSearchParams(); fetchUser(id)
// BIEN: validar con Zod antes de usar
// MAL: objeto de estilo en línea recreado en cada renderizado en una ruta crítica
<View style={{ padding: 16, backgroundColor: '#fff' }} />
// BIEN: StyleSheet.create en el ámbito del módulo, o className de NativeWind
// MAL: secreto real enviado en el bundle
const STRIPE_SECRET = 'sk_live_...'
// BIEN: mantener llamadas privilegiadas del lado del servidor; enviar solo claves públicas protegidas por reglas del backend
Mejores prácticas
- Mantén los archivos de ruta livianos; coloca la lógica en componentes de pantalla y hooks
use*. - Valida cada entrada externa (respuestas de API, parámetros de ruta, cargas push) con Zod.
- Deja que TanStack Query posea el estado del servidor; mantén los stores del cliente pequeños.
- Siempre renderiza estados de carga, error y vacío — nunca solo un spinner sin respaldo.
- Virtualiza listas; memoriza
renderItem; proporciona unkeyExtractorestable. - Usa
react-native-reanimatedpara animaciones (hilo de UI); evita trabajo pesado en el hilo de JS. - Almacena tokens en
expo-secure-store; nunca confíes en el cliente para la autorización. - Respeta las áreas seguras, el Tipo Dinámico y los roles/etiquetas de accesibilidad desde el principio.
- Confirma la compatibilidad con la Nueva Arquitectura para cada dependencia nativa antes del lanzamiento.
Skills relacionados
frontend-patterns— Patrones de React/Next.js (web); útil para conceptos compartidos de React, pero específicos del DOM.coding-standards— Modismos de TypeScript/JavaScript que se aplican al código RN.tdd-workflow,e2e-testing— Proceso de pruebas (usa Jest + React Native Testing Library, Maestro/Detox para RN).security-review— Lista de verificación de seguridad general que complementa la guía de bundle/secretos de RN anterior.