Todo lo que has construido en este curso vive dentro de un navegador. Los componentes producen elementos del DOM, los estilos son CSS, los eventos son eventos del DOM y el almacenamiento es localStorage. Esta última lección del módulo hace el salto más grande: llevar lo aprendido fuera del navegador, a una aplicación móvil que se instala desde una tienda y que dibuja vistas nativas de verdad. La buena noticia es que la mayor parte de lo que sabes se transfiere intacto —componentes, props, estado, hooks, contexto, Redux, TanStack Query, los tipos de TypeScript de la lección anterior—; lo que cambia es la capa de presentación y las APIs de plataforma. Vas a montar CicloUrbanoMovil con Expo, portar TarjetaBicicleta, EtiquetaEstado y ListaBicicletas, entender por qué FlatList no es un map, navegar con Expo Router, y saber exactamente qué código del proyecto web puedes copiar sin tocar una línea. El objetivo no es dominar React Native en una lección, sino entender el modelo, sus límites y qué se reutiliza, para que el salto sea una decisión informada.
Contenido
- Qué es React Native y en qué se diferencia de las alternativas
- Qué se comparte con React y qué no
- La arquitectura en una idea
- Expo: crear
CicloUrbanoMovil - Componentes básicos y su equivalencia con la web
- Estilos:
StyleSheety las diferencias con CSS - Portar
EtiquetaEstadoyTarjetaBicicleta ListaBicicletasconFlatList- Qué se reutiliza tal cual del proyecto web
- Navegación con Expo Router
- Capacidades del dispositivo
- Diferencias de plataforma y accesibilidad móvil
- Depuración y pruebas
- Publicación en las tiendas
- Cierre del módulo y puente al proyecto final
- Qué es React Native y en qué se diferencia de las alternativas
React Native es React con otro objetivo de renderizado. El mismo React que conoces —el mismo reconciliador, los mismos hooks, la misma reconciliación del módulo 1— pero en lugar de producir nodos del DOM produce vistas nativas: un UIView en iOS, un android.view.View en Android.
Eso significa que un <Text> de React Native no es un <p> disfrazado. Es un UILabel real, con el desplazamiento del sistema, la selección del sistema, la accesibilidad del sistema y el rendimiento del sistema.
Comparativa con las opciones que suelen ponerse sobre la mesa:
| Web móvil | PWA | Híbrida (Cordova/Ionic) | React Native | Nativa (Swift/Kotlin) | |
|---|---|---|---|---|---|
| Qué se ejecuta | HTML/CSS/JS en el navegador | Igual, con service worker | HTML/CSS/JS en un WebView | JS + vistas nativas | Código nativo |
| Interfaz | Elementos del DOM | Elementos del DOM | Elementos del DOM | Componentes nativos | Componentes nativos |
| Instalable | No | Parcialmente | Sí | Sí | Sí |
| En las tiendas | No | Limitado | Sí | Sí | Sí |
| Acceso al dispositivo | Muy limitado | Limitado | Vía plugins | Amplio | Total |
| Sensación al usar | De web | De web | De web | Casi nativa | Nativa |
| Un código, dos plataformas | Sí | Sí | Sí | Sí (~85-95 %) | No |
| Curva desde React | Ninguna | Baja | Baja | Media | Alta |
La diferencia que decide entre híbrida y React Native es la fila de la interfaz. Una aplicación híbrida simula los componentes del sistema con HTML y CSS: el desplazamiento no tiene la misma inercia, los formularios no se comportan igual y el usuario lo nota aunque no sepa decir por qué. React Native usa los componentes de verdad.
Y la honestidad sobre el porcentaje compartido: el 85-95 % es de la lógica, no de la interfaz completa. La navegación, los espaciados, los gestos y varias convenciones difieren entre iOS y Android, y una aplicación cuidada trata esas diferencias en lugar de ignorarlas.
- Qué se comparte con React y qué no
Esta tabla es el mapa mental de la lección.
| Se comparte ✅ | No existe ❌ |
|---|---|
| Componentes y JSX | El DOM (document, window) |
| Props y composición | Las etiquetas HTML (div, p, span, img) |
useState, useEffect, useRef, useReducer, useMemo, useCallback |
CSS: hojas de estilo, cascada, selectores |
useContext y la API de contexto |
localStorage, sessionStorage |
| Hooks personalizados (si no tocan el DOM) | fetch sí existe; XMLHttpRequest también |
memo, lazy, Suspense, límites de error |
React Router (se usa Expo Router) |
Redux Toolkit y react-redux |
CSS Modules |
| TanStack Query | alert, prompt (hay Alert de React Native) |
| Tipos de TypeScript | Elementos SVG en línea (hay react-native-svg) |
| Lógica de validación y utilidades puras | Etiquetas semánticas (nav, header, main) |
Lo importante de esta tabla es dónde está la frontera: todo lo que es React se comparte; todo lo que es web no. La distinción que en 10-03 separaba servidor de cliente aquí separa lógica de presentación, y es igual de útil para decidir dónde va cada cosa.
- La arquitectura en una idea
Cómo funciona por dentro, en el nivel de detalle que hace falta:
flowchart TB
subgraph JS["Hilo de JavaScript"]
A["Tus componentes de React"] --> B["React · reconciliacion"]
B --> C["Descripcion del arbol de vistas"]
end
C -->|"JSI · llamadas directas"| D
subgraph NATIVO["Hilo de interfaz nativo"]
D["Fabric · renderizador"] --> E["UIView (iOS)<br/>android.view.View (Android)"]
E --> F["Pantalla"]
end
F -->|"toques y gestos"| A
La idea en una frase: tus componentes describen la interfaz y el sistema pinta vistas nativas de verdad. Tú no manipulas vistas; describes cómo debe verse el estado actual y React se encarga del resto. Es exactamente el modelo declarativo del módulo 1, con otro destino.
Sobre la arquitectura nueva (activada por defecto desde React Native 0.76), basta con saber tres nombres y qué resuelven:
- JSI (JavaScript Interface): permite que JavaScript llame al código nativo directamente, sin serializar mensajes JSON a través de un puente asíncrono. Elimina el cuello de botella histórico de React Native.
- Fabric: el renderizador nuevo, que permite operaciones síncronas y hace posible que la interfaz responda en el mismo cuadro.
- TurboModules: los módulos nativos se cargan cuando se usan, no todos al arrancar, lo que reduce el tiempo de inicio.
No hace falta más para trabajar. Lo relevante en la práctica es que los problemas de rendimiento clásicos de React Native —listas que van a tirones, animaciones que se atascan— han mejorado mucho, y que los principios del módulo 8 (evitar renders innecesarios, memorizar, virtualizar) siguen siendo válidos y aquí importan todavía más, porque un móvil tiene menos margen que un portátil.
- Expo: crear
CicloUrbanoMovil
CicloUrbanoMovilSe puede usar React Native «desnudo» con la CLI oficial, pero eso obliga a tener Xcode y Android Studio configurados desde el minuto uno, y a gestionar a mano la configuración nativa. Expo es la forma recomendada de empezar, y la que recomienda la documentación oficial de React Native.
El último comando muestra un código QR en el terminal. Instalas Expo Go en tu móvil, escaneas el código y la aplicación se abre en el dispositivo real, con recarga en caliente al guardar. Sin cables, sin firmar nada, sin Xcode.
Qué aporta Expo:
| Pieza | Para qué sirve |
|---|---|
| Expo Go | Ejecutar la aplicación en un móvil real sin compilar |
| SDK de Expo | Bibliotecas para cámara, ubicación, notificaciones, ficheros, sensores… ya integradas |
| Expo Router | Navegación por ficheros, como el App Router de 10-01 |
| EAS Build | Compilar los binarios de iOS y Android en la nube, sin Mac |
| EAS Update | Publicar cambios de JavaScript por aire, sin pasar por revisión |
| Módulos de desarrollo | Compilar una versión propia cuando necesitas código nativo que Expo Go no incluye |
Estructura del proyecto recién creado:
CicloUrbanoMovil/ ├── app/ ← rutas por ficheros (Expo Router) │ ├── _layout.tsx ← plantilla raíz │ ├── index.tsx ← pantalla inicial "/" │ └── +not-found.tsx ├── components/ ├── assets/ ← imágenes y fuentes ├── app.json ← configuración: nombre, icono, permisos ├── tsconfig.json ← TypeScript viene configurado de fábrica └── package.json
Reconocerás el patrón de app/: es el mismo enrutamiento por carpetas de Next.js. Y fíjate en que las plantillas de Expo vienen con TypeScript ya configurado, así que todo lo de 10-04 aplica directamente.
Adaptaremos la estructura a las convenciones del curso:
CicloUrbanoMovil/ ├── app/ │ ├── _layout.tsx │ ├── (pestanas)/ │ │ ├── _layout.tsx │ │ ├── index.tsx → catálogo │ │ ├── estaciones.tsx │ │ └── reservas.tsx │ └── bicicletas/[bicicletaId].tsx ├── componentes/ ├── hooks/ ├── utilidades/ ├── tipos/ ← copiado del proyecto web └── consultas/
- Componentes básicos y su equivalencia con la web
No hay etiquetas HTML: hay componentes importados de react-native.
| React Native | Equivalente web | Notas |
|---|---|---|
<View> |
<div> |
Contenedor. No puede contener texto suelto |
<Text> |
<p>, <span>, <h1> |
Todo el texto va dentro de un Text, sin excepción |
<Image> |
<img> |
source={{ uri }} o require() para local |
<TextInput> |
<input>, <textarea> |
onChangeText da el texto directamente |
<Pressable> |
<button>, <a> |
El estándar actual para cualquier zona pulsable |
<TouchableOpacity> |
<button> |
Alternativa con retroalimentación de opacidad |
<ScrollView> |
<div style="overflow:scroll"> |
Renderiza todos los hijos a la vez |
<FlatList> |
Lista con .map() |
Virtualizada: solo renderiza lo visible |
<SectionList> |
Lista agrupada | Con cabeceras de sección |
<SafeAreaView> |
— | Evita la muesca, la barra de estado y el indicador inferior |
<Modal> |
<dialog> |
Superposición nativa |
<ActivityIndicator> |
Un spinner propio | El indicador de carga del sistema |
<Switch> |
<input type="checkbox"> |
Interruptor nativo |
La regla que más se olvida al empezar, y merece destacarse:
// ❌ ERROR: "Text strings must be rendered within a <Text> component"
<View>Eléctrica Pro</View>
// ✅ CORRECTO
<View>
<Text>Eléctrica Pro</Text>
</View>En la web, cualquier elemento puede contener texto. En React Native, solo <Text> puede. La razón es que el texto se mide y se pinta con el motor tipográfico nativo, que es un componente distinto de una vista contenedora. A cambio, <Text> sí puede anidarse y hereda estilos, que es la única herencia de estilos que existe en React Native:
<Text style={{ fontSize: 16, color: '#1f2933' }}>
Precio: <Text style={{ fontWeight: '700' }}>4,00 €/h</Text>
</Text>Y sobre lo pulsable, la recomendación actual es Pressable, que da control fino sobre los estados:
<Pressable
onPress={() => console.log('pulsado')}
onLongPress={() => console.log('pulsación larga')}
style={({ pressed }) => [estilos.tarjeta, pressed && estilos.tarjetaPulsada]}
accessibilityRole="button"
accessibilityLabel="Ver ficha de Eléctrica Pro"
>
<Text>Ver ficha</Text>
</Pressable>Fíjate en style como función que recibe { pressed }: es el sustituto de :active de CSS, y es una buena muestra de cómo se resuelven aquí los estados visuales.
- Estilos:
StyleSheet y las diferencias con CSS
StyleSheet y las diferencias con CSSNo hay CSS. Los estilos son objetos de JavaScript, y se agrupan con StyleSheet.create:
import { StyleSheet } from 'react-native';
const estilos = StyleSheet.create({
tarjeta: {
backgroundColor: '#ffffff',
borderRadius: 12,
padding: 16,
marginBottom: 12,
borderWidth: 1,
borderColor: '#d9e2ec',
},
modelo: {
fontSize: 18,
fontWeight: '600',
color: '#1f2933',
marginBottom: 4,
},
precio: {
fontSize: 16,
color: '#12805c',
},
});Diferencias frente a CSS, que son muchas y conviene tener presentes:
| CSS (web) | React Native |
|---|---|
background-color: #fff; |
backgroundColor: '#ffffff' (camelCase) |
padding: 16px; |
padding: 16 (números sin unidad, en puntos independientes de la densidad) |
font-weight: 600; |
fontWeight: '600' (cadena) |
display: flex; |
Implícito: todo es Flexbox |
flex-direction: row; por defecto |
flexDirection: 'column' por defecto |
| Cascada y herencia | No existe (salvo texto anidado dentro de Text) |
Selectores (.clase, :hover) |
No existen: el estilo se pasa por prop |
| Media queries | useWindowDimensions() o Platform |
:hover, :focus, :active |
Función de style con { pressed }, o estado propio |
box-shadow |
shadowColor/shadowOffset/shadowOpacity (iOS) + elevation (Android) |
Unidades %, vh, rem |
% en algunos casos; nada de vh ni rem |
position: fixed |
No existe; absolute y relative sí |
gap |
Sí, soportado |
Las dos diferencias que más desconciertan al principio:
flexDirection: 'column' por defecto. En CSS, un contenedor flex apila en fila; aquí apila en columna, que es lo habitual en una pantalla vertical. Si algo aparece apilado cuando lo esperabas en línea, es esto.
No hay cascada. Un estilo puesto en la View padre no lo heredan los hijos. Cada componente lleva el suyo. Puede parecer un retroceso, pero elimina de un plumazo la clase de errores más frecuente en CSS: el estilo que llega de un ancestro que nadie recuerda. Es la misma filosofía de los CSS Modules del módulo 2, llevada al extremo.
Los estilos se combinan pasando un array, y gana el último:
Y las variables de tema de CicloUrbano se trasladan a un módulo de constantes, ya que no hay :root:
// tema/colores.ts
export const colores = {
marca: '#12805c',
alquilada: '#b45309',
mantenimiento: '#9b1c1c',
fondo: '#f5f7fa',
superficie: '#ffffff',
texto: '#1f2933',
borde: '#d9e2ec',
} as const;El as const de 10-04 hace que cada valor sea un literal, lo que da autocompletado exacto al usarlo.
- Portar
EtiquetaEstado y TarjetaBicicleta
EtiquetaEstado y TarjetaBicicletaAquí se ve lo que de verdad cambia. La estructura de props y la lógica se mantienen idénticas; solo cambia la capa de presentación.
// componentes/EtiquetaEstado.tsx
import { View, Text, StyleSheet } from 'react-native';
import type { EstadoBicicleta } from '../tipos/dominio';
import { colores } from '../tema/colores';
type Props = {
estado: EstadoBicicleta;
};
const TEXTOS: Record<EstadoBicicleta, string> = {
disponible: 'Disponible',
alquilada: 'Alquilada',
mantenimiento: 'En mantenimiento',
};
const FONDOS: Record<EstadoBicicleta, string> = {
disponible: colores.marca,
alquilada: colores.alquilada,
mantenimiento: colores.mantenimiento,
};
function EtiquetaEstado({ estado }: Props) {
return (
<View style={[estilos.etiqueta, { backgroundColor: FONDOS[estado] }]}>
<Text style={estilos.texto}>{TEXTOS[estado]}</Text>
</View>
);
}
const estilos = StyleSheet.create({
etiqueta: {
alignSelf: 'flex-start',
paddingHorizontal: 10,
paddingVertical: 4,
borderRadius: 999,
},
texto: {
color: '#ffffff',
fontSize: 12,
fontWeight: '600',
},
});
export default EtiquetaEstado;Compara con la versión web de 10-04: el tipo Props, el objeto TEXTOS y la firma son exactamente los mismos. Lo único que cambia es <span> → <View> + <Text>, y estilos.etiqueta de CSS Modules → StyleSheet. Un Record<EstadoBicicleta, string> sigue obligando a cubrir los tres estados.
// componentes/TarjetaBicicleta.tsx
import { View, Text, Pressable, StyleSheet } from 'react-native';
import { useRouter } from 'expo-router';
import EtiquetaEstado from './EtiquetaEstado';
import type { Bicicleta } from '../tipos/dominio';
import { colores } from '../tema/colores';
type Props = {
bicicleta: Bicicleta;
alSeleccionar?: (bicicletaId: string) => void;
};
function TarjetaBicicleta({ bicicleta, alSeleccionar }: Props) {
const router = useRouter();
function manejarPulsacion() {
if (alSeleccionar) alSeleccionar(bicicleta.id);
else router.push(`/bicicletas/${bicicleta.id}`);
}
return (
<Pressable
onPress={manejarPulsacion}
style={({ pressed }) => [estilos.tarjeta, pressed && estilos.pulsada]}
accessibilityRole="button"
accessibilityLabel={`${bicicleta.modelo}, ${bicicleta.estado}, ${bicicleta.precioHora} euros por hora`}
>
<View style={estilos.cabecera}>
<Text style={estilos.modelo}>{bicicleta.modelo}</Text>
<EtiquetaEstado estado={bicicleta.estado} />
</View>
<Text style={estilos.detalle}>{bicicleta.tipo}</Text>
<Text style={estilos.precio}>{bicicleta.precioHora.toFixed(2)} €/h</Text>
</Pressable>
);
}
const estilos = StyleSheet.create({
tarjeta: {
backgroundColor: colores.superficie,
borderRadius: 12,
borderWidth: 1,
borderColor: colores.borde,
padding: 16,
marginBottom: 12,
},
pulsada: { opacity: 0.7 },
cabecera: {
flexDirection: 'row', // en vertical por defecto: hay que pedirlo
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: 8,
},
modelo: { fontSize: 18, fontWeight: '600', color: colores.texto },
detalle: { fontSize: 14, color: '#6b7785', marginBottom: 4 },
precio: { fontSize: 16, fontWeight: '600', color: colores.marca },
});
export default TarjetaBicicleta;La convención de props del curso se respeta al pie de la letra: manejador interno manejarPulsacion, prop hacia fuera alSeleccionar. Lo único específico de la plataforma es Pressable en lugar de <Link>, accessibilityLabel en lugar de aria-label, y flexDirection: 'row' explícito en la cabecera.
ListaBicicletas con FlatList
ListaBicicletas con FlatListAquí está la diferencia conceptual más importante de la lección, y enlaza directamente con la virtualización de 08-01.
En la web escribías esto:
Con 5 bicicletas funciona. Con 500 en un móvil, la aplicación se bloquea: se crean 500 vistas nativas de golpe, se consume memoria y el hilo de interfaz se queda sin margen. Un <ScrollView> con map dentro tiene exactamente el mismo problema, porque también renderiza todos sus hijos.
FlatList es una lista virtualizada: solo mantiene en memoria los elementos visibles y unos pocos alrededor, y reutiliza las vistas al desplazarse.
// componentes/ListaBicicletas.tsx
import { FlatList, View, Text, StyleSheet, RefreshControl } from 'react-native';
import TarjetaBicicleta from './TarjetaBicicleta';
import type { Bicicleta } from '../tipos/dominio';
type Props = {
bicicletas: Bicicleta[];
recargando?: boolean;
alRecargar?: () => void;
};
function ListaBicicletas({ bicicletas, recargando = false, alRecargar }: Props) {
return (
<FlatList
data={bicicletas}
keyExtractor={(bicicleta) => bicicleta.id}
renderItem={({ item }) => <TarjetaBicicleta bicicleta={item} />}
contentContainerStyle={estilos.contenido}
ListEmptyComponent={
<View style={estilos.vacio}>
<Text>No hay bicicletas con ese filtro.</Text>
</View>
}
ListHeaderComponent={<Text style={estilos.titulo}>Catálogo</Text>}
ItemSeparatorComponent={() => <View style={estilos.separador} />}
refreshControl={
alRecargar
? <RefreshControl refreshing={recargando} onRefresh={alRecargar} />
: undefined
}
initialNumToRender={8}
removeClippedSubviews
/>
);
}
const estilos = StyleSheet.create({
contenido: { padding: 16 },
titulo: { fontSize: 24, fontWeight: '700', marginBottom: 16 },
vacio: { paddingVertical: 48, alignItems: 'center' },
separador: { height: 4 },
});
export default ListaBicicletas;Las props esenciales:
| Prop | Qué hace |
|---|---|
data |
El array de elementos |
renderItem |
Función que recibe { item, index } y devuelve el elemento |
keyExtractor |
La clave estable de cada elemento (el key del módulo 3) |
ListEmptyComponent |
Qué mostrar con la lista vacía |
ListHeaderComponent / ListFooterComponent |
Cabecera y pie que se desplazan con la lista |
ItemSeparatorComponent |
Separador entre elementos |
onEndReached |
Se dispara al acercarse al final: paginación infinita |
refreshControl |
El gesto de «tirar para recargar», nativo |
initialNumToRender |
Cuántos elementos pintar en el primer cuadro |
Por qué FlatList no es un map, en tabla:
.map() en ScrollView |
FlatList |
|
|---|---|---|
| Vistas creadas con 500 elementos | 500 | ~15 |
| Memoria | Crece con la lista | Constante |
| Tiempo del primer pintado | Crece con la lista | Constante |
| Reutiliza vistas al desplazarse | No | Sí |
| Cuándo usarlo | Listas cortas y fijas (< 20) | Cualquier lista con datos |
Y aquí conecta el módulo 8 de forma directa: la virtualización que allí se planteaba como una técnica avanzada que había que añadir con una biblioteca, en React Native viene de serie y es el camino por defecto. La razón es el contexto: en un móvil, ese margen no es opcional.
Un consejo de rendimiento que también viene de allí: envuelve TarjetaBicicleta en memo y define renderItem con useCallback. Al desplazarse, FlatList vuelve a renderizar con frecuencia, y evitar renders innecesarios se nota en la fluidez de forma mucho más visible que en la web.
- Qué se reutiliza tal cual del proyecto web
Esta es la pregunta práctica: cuánto código se copia sin tocar.
| Del proyecto web | ¿Se reutiliza? | Detalle |
|---|---|---|
tipos/dominio.ts |
✅ Tal cual | No depende de nada |
utilidades/validarReserva.ts |
✅ Tal cual | Función pura |
utilidades/disponibilidad.ts |
✅ Tal cual | Función pura |
utilidades/clases.js |
❌ | Une clases CSS; aquí no aplica |
| Esquemas de Zod | ✅ Tal cual | Validación en el límite, igual que en 10-04 |
almacen/ y los slices de Redux |
✅ Tal cual | Redux Toolkit no sabe nada del DOM |
consultas/ de TanStack Query |
✅ Casi tal cual | fetch existe; solo cambia la URL base |
hooks/useAlternar |
✅ Tal cual | Solo useState |
hooks/useDebounce, useThrottle |
✅ Tal cual | Solo temporizadores |
hooks/useValorPrevio |
✅ Tal cual | Solo useRef |
hooks/useCatalogoFiltrado |
✅ Tal cual | Lógica pura |
hooks/useAlmacenLocal |
⚠️ Adaptar | localStorage → AsyncStorage (y pasa a ser asíncrono) |
hooks/useAnchoVentana |
⚠️ Adaptar | → useWindowDimensions() de React Native |
hooks/useEventoTeclado |
❌ | No hay teclado físico como evento global |
hooks/useEstadoConexion |
⚠️ Adaptar | → @react-native-community/netinfo |
contextos/ (tema, avisos) |
✅ Lógica sí | La presentación se reescribe |
Componentes (TarjetaBicicleta, etc.) |
❌ Reescribir | La capa de presentación es distinta |
rutas.jsx |
❌ | → Expo Router |
flowchart TB
subgraph COMPARTIDO["Nucleo compartido · se copia sin cambios"]
T["tipos/dominio.ts"]
U["utilidades: validarReserva, disponibilidad"]
Z["esquemas de Zod"]
R["almacen: sliceSesion, sliceCatalogo, sliceReservas"]
Q["consultas de TanStack Query"]
H["hooks sin DOM: useAlternar, useDebounce, useValorPrevio"]
end
COMPARTIDO --> WEB
COMPARTIDO --> MOVIL
subgraph WEB["ciclourbano-web / SPA Vite"]
W1["Componentes con div, span, CSS Modules"]
W2["React Router / App Router"]
end
subgraph MOVIL["CicloUrbanoMovil"]
M1["Componentes con View, Text, StyleSheet"]
M2["Expo Router"]
end
API[("API comun")]
WEB --> API
MOVIL --> API
En un equipo que mantiene las dos aplicaciones, ese núcleo compartido se extrae a un paquete (@ciclourbano/nucleo) dentro de un monorepo. Y fíjate en la consecuencia práctica de 10-04: tipos/dominio.ts es lo primero que se comparte, porque los tipos no tienen dependencias y ahora los dos proyectos hablan del mismo dominio verificado.
La sustitución más habitual, useAlmacenLocal:
// hooks/useAlmacenLocal.ts (versión móvil)
import { useState, useEffect } from 'react';
import AsyncStorage from '@react-native-async-storage/async-storage';
export function useAlmacenLocal<T>(clave: string, valorInicial: T) {
const [valor, setValor] = useState<T>(valorInicial);
const [cargado, setCargado] = useState(false);
// AsyncStorage es ASÍNCRONO: no se puede leer en el inicializador de useState.
useEffect(() => {
AsyncStorage.getItem(clave).then((guardado) => {
if (guardado !== null) setValor(JSON.parse(guardado) as T);
setCargado(true);
});
}, [clave]);
useEffect(() => {
if (cargado) AsyncStorage.setItem(clave, JSON.stringify(valor));
}, [clave, valor, cargado]);
return [valor, setValor, cargado] as const;
}La diferencia clave: localStorage es síncrono y AsyncStorage no, así que aparece un tercer valor, cargado, que indica si ya se leyó la preferencia. Es el mismo problema del tema oscuro que trataste en 10-01 con la hidratación, con otra causa.
- Navegación con Expo Router
Expo Router usa enrutamiento por ficheros, exactamente como el App Router de 10-01. Si entendiste aquello, esto ya lo sabes.
app/ ├── _layout.tsx → plantilla raíz ├── (pestanas)/ │ ├── _layout.tsx → navegador de pestañas │ ├── index.tsx → "/" catálogo │ ├── estaciones.tsx → "/estaciones" │ └── reservas.tsx → "/reservas" ├── bicicletas/ │ └── [bicicletaId].tsx → "/bicicletas/bici-002" └── +not-found.tsx → ruta desconocida
Equivalencias con React Router, que es lo que ya conoces del módulo 6:
| React Router | Expo Router |
|---|---|
createBrowserRouter([...]) |
El árbol de app/ |
<Route path="/estaciones"> |
app/estaciones.tsx |
<Route path="/bicicletas/:bicicletaId"> |
app/bicicletas/[bicicletaId].tsx |
<Outlet /> |
<Stack />, <Tabs /> o <Slot /> en _layout.tsx |
<Link to="/estaciones"> |
<Link href="/estaciones"> de expo-router |
useNavigate() |
useRouter() |
useParams() |
useLocalSearchParams() |
useSearchParams() |
useLocalSearchParams() |
Ruta comodín * |
+not-found.tsx |
RutaProtegida |
Redirección en el _layout o <Redirect /> |
La plantilla de pestañas:
// app/(pestanas)/_layout.tsx
import { Tabs } from 'expo-router';
import { colores } from '../../tema/colores';
export default function PlantillaPestanas() {
return (
<Tabs
screenOptions={{
tabBarActiveTintColor: colores.marca,
headerStyle: { backgroundColor: colores.superficie },
}}
>
<Tabs.Screen name="index" options={{ title: 'Catálogo' }} />
<Tabs.Screen name="estaciones" options={{ title: 'Estaciones' }} />
<Tabs.Screen name="reservas" options={{ title: 'Mis reservas' }} />
</Tabs>
);
}Y una pantalla de detalle con parámetro dinámico:
// app/bicicletas/[bicicletaId].tsx
import { View, Text, ActivityIndicator, StyleSheet } from 'react-native';
import { useLocalSearchParams, Stack } from 'expo-router';
import { useBicicleta } from '../../consultas/bicicletas';
import EtiquetaEstado from '../../componentes/EtiquetaEstado';
export default function PantallaFichaBicicleta() {
const { bicicletaId } = useLocalSearchParams<{ bicicletaId: string }>();
const { data: bicicleta, isPending, isError } = useBicicleta(bicicletaId);
if (isPending) return <ActivityIndicator style={estilos.centro} size="large" />;
if (isError || !bicicleta) return <Text style={estilos.centro}>No encontrada.</Text>;
return (
<View style={estilos.contenedor}>
{/* Configura la cabecera de la pila desde la propia pantalla */}
<Stack.Screen options={{ title: bicicleta.modelo }} />
<Text style={estilos.titulo}>{bicicleta.modelo}</Text>
<EtiquetaEstado estado={bicicleta.estado} />
<Text style={estilos.precio}>{bicicleta.precioHora.toFixed(2)} €/h</Text>
</View>
);
}
const estilos = StyleSheet.create({
contenedor: { flex: 1, padding: 16 },
centro: { flex: 1, textAlign: 'center', marginTop: 48 },
titulo: { fontSize: 24, fontWeight: '700', marginBottom: 8 },
precio: { fontSize: 18, color: '#12805c', marginTop: 8 },
});Dos conceptos de navegación móvil que no tienen equivalente web:
- Pila (stack): las pantallas se apilan y se vuelve atrás con el gesto o el botón físico de Android. La animación de entrada por el lateral es parte de la convención de la plataforma.
- Pestañas (tabs): la barra inferior con las secciones principales. Cada pestaña conserva su propia pila y su estado al cambiar de una a otra, algo que en la web habría requerido trabajo explícito.
Y fíjate en que useBicicleta es la misma consulta de TanStack Query del proyecto web: la capa de datos se reutiliza intacta.
- Capacidades del dispositivo
Es la razón principal por la que una empresa como CicloUrbano querría una aplicación móvil y no solo una web. Tres ejemplos con sentido de producto:
Ubicación: encontrar la estación más cercana.
import * as Location from 'expo-location';
export async function obtenerEstacionMasCercana(estaciones: Estacion[]) {
const { status } = await Location.requestForegroundPermissionsAsync();
if (status !== 'granted') {
throw new Error('Permiso de ubicación denegado');
}
const posicion = await Location.getCurrentPositionAsync({});
const { latitude, longitude } = posicion.coords;
return estaciones
.map((estacion) => ({
estacion,
distancia: calcularDistancia(latitude, longitude, estacion.lat, estacion.lon),
}))
.sort((a, b) => a.distancia - b.distancia)[0]?.estacion;
}Cámara: leer el código QR de la bicicleta para desbloquearla.
import { CameraView, useCameraPermissions } from 'expo-camera';
function EscanerQR({ alEscanear }: { alEscanear: (codigo: string) => void }) {
const [permiso, pedirPermiso] = useCameraPermissions();
if (!permiso?.granted) {
return <Button title="Permitir cámara" onPress={pedirPermiso} />;
}
return (
<CameraView
style={{ flex: 1 }}
barcodeScannerSettings={{ barcodeTypes: ['qr'] }}
onBarcodeScanned={({ data }) => alEscanear(data)}
/>
);
}Notificaciones: avisar de que la reserva termina en 10 minutos.
import * as Notifications from 'expo-notifications';
export async function programarAvisoFinReserva(reserva: Reserva) {
const { status } = await Notifications.requestPermissionsAsync();
if (status !== 'granted') return;
const fin = new Date(reserva.fechaInicio).getTime() + reserva.horas * 3_600_000;
const aviso = new Date(fin - 10 * 60_000);
await Notifications.scheduleNotificationAsync({
content: {
title: 'Tu reserva termina pronto',
body: 'Quedan 10 minutos. Devuelve la bicicleta a una estación para no pagar de más.',
},
trigger: { type: 'date', date: aviso },
});
}Advertencia sobre permisos y privacidad. Cada una de estas capacidades exige el permiso explícito del usuario, y se puede denegar. Tres reglas que no son opcionales: (1) pide el permiso cuando lo necesites, no al arrancar, y explica antes para qué; (2) la aplicación debe funcionar sin él —si deniegan la ubicación, muestra la lista de estaciones ordenada alfabéticamente—; (3) declara los usos en
app.jsoncon textos comprensibles: Apple y Google rechazan aplicaciones con justificaciones genéricas, y la ubicación en segundo plano es objeto de revisión especial.
- Diferencias de plataforma y accesibilidad móvil
iOS y Android no son iguales, y una aplicación cuidada lo reconoce en lugar de forzar un diseño único.
import { Platform, StyleSheet } from 'react-native';
const estilos = StyleSheet.create({
tarjeta: {
backgroundColor: '#ffffff',
borderRadius: 12,
...Platform.select({
ios: {
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.1,
shadowRadius: 4,
},
android: {
elevation: 3, // Android usa su propio sistema de elevación
},
}),
},
cabecera: {
paddingTop: Platform.OS === 'ios' ? 44 : 24,
},
});Diferencias que más se notan:
| Aspecto | iOS | Android |
|---|---|---|
| Sombras | shadow* |
elevation |
| Botón atrás físico | No existe | Sí: hay que tratarlo |
| Tipografía por defecto | San Francisco | Roboto |
| Volver atrás | Gesto desde el borde | Botón o gesto |
| Fuentes | Se cargan con expo-font |
Igual |
| Permisos | Diálogo del sistema, una sola vez | Se puede volver a pedir |
Accesibilidad. Los conceptos del módulo 3 se trasladan con otros nombres, y en móvil importan igual o más porque VoiceOver (iOS) y TalkBack (Android) son de uso muy extendido:
| Web (módulo 3) | React Native |
|---|---|
aria-label |
accessibilityLabel |
role="button" |
accessibilityRole="button" |
aria-disabled |
accessibilityState={{ disabled: true }} |
aria-live="polite" |
accessibilityLiveRegion="polite" |
| Orden de tabulación | Orden natural del árbol |
| Foco visible | Menos relevante: el lector anuncia |
Dos reglas propias del móvil: la zona pulsable mínima es de unos 44×44 puntos (usa hitSlop si el elemento visual es más pequeño), y el contraste debe seguir cumpliendo la ratio 4,5:1 —los colores de CicloUrbano ya lo cumplen—.
- Depuración y pruebas
En breve, porque los conceptos ya los tienes del módulo 9.
Depuración:
- Recarga rápida (Fast Refresh): activa por defecto, conserva el estado al guardar.
- Menú de desarrollo: agitando el dispositivo o con
men el terminal de Expo. - React DevTools: funcionan igual, incluido el Profiler de 08-05.
- Depurador de Expo: inspector de red, consola y puntos de interrupción de JavaScript.
console.log: aparece en el terminal donde correnpx expo start.
Pruebas, con las mismas herramientas del módulo 9 y equivalentes:
| Nivel | Web (módulo 9) | React Native |
|---|---|---|
| Unitarias | Vitest / Jest | Jest (configurado por Expo) |
| Componentes | React Testing Library | @testing-library/react-native |
| Simulación de red | MSW | MSW (compatible) |
| Extremo a extremo | Cypress | Maestro o Detox |
La API de Testing Library es casi idéntica, y el principio rector de 09-01 —probar el comportamiento, no la implementación— se mantiene sin cambios:
import { render, screen, fireEvent } from '@testing-library/react-native';
import TarjetaBicicleta from '../componentes/TarjetaBicicleta';
const BICI = {
id: 'bici-002', modelo: 'Eléctrica Pro', tipo: 'electrica',
estado: 'alquilada', estacionId: 'est-01', precioHora: 4.0,
} as const;
test('muestra el modelo y avisa al pulsar', () => {
const alSeleccionar = jest.fn();
render(<TarjetaBicicleta bicicleta={BICI} alSeleccionar={alSeleccionar} />);
expect(screen.getByText('Eléctrica Pro')).toBeTruthy();
fireEvent.press(screen.getByRole('button'));
expect(alSeleccionar).toHaveBeenCalledWith('bici-002');
});La diferencia visible: fireEvent.press en lugar de click, y getByText devuelve un nodo de React Native en lugar de un elemento del DOM. Las consultas por rol siguen siendo la forma preferida, igual que en 09-03, y por el mismo motivo: si el elemento no tiene rol, tampoco lo tiene para el lector de pantalla.
- Publicación en las tiendas
Un resumen de lo que implica, porque cambia la forma de planificar las entregas.
npm install -g eas-cli
eas build --platform all # compila iOS y Android en la nube
eas submit --platform all # envía a App Store y Google PlayLo que hay que saber antes de comprometer fechas:
| Aspecto | App Store (Apple) | Google Play |
|---|---|---|
| Cuenta de desarrollador | 99 $/año | 25 $ una vez |
| Revisión de la primera versión | Días | Horas o días |
| Revisión de actualizaciones | Horas o días | Normalmente horas |
| Rechazos frecuentes | Permisos mal justificados, poco contenido | Políticas de privacidad, permisos |
| Se necesita un Mac | Sí para compilar en local; no con EAS Build | No |
Y la pieza que cambia el ritmo de trabajo: las actualizaciones por aire. Con eas update, un cambio que solo toque JavaScript —una corrección de texto, un ajuste de estilo, un error de lógica— se publica sin pasar por revisión:
Los usuarios lo reciben al siguiente arranque. Lo que sí requiere una versión nueva y su revisión es cualquier cambio en código nativo: una biblioteca nueva con parte nativa, un permiso nuevo, un cambio de icono o de nombre. La regla, que conviene tener escrita en el equipo: JavaScript por aire, código nativo por la tienda.
- Cierre del módulo y puente al proyecto final
El Módulo 10 ha llevado React a cinco terrenos que no existían al empezar:
- 10-01 · SSR con Next.js: el HTML llega construido; las cuatro estrategias de renderizado; la hidratación y sus discrepancias;
ciclourbano-webcon App Router. - 10-02 · SSG e ISR: generar una vez y revalidar;
generateStaticParams,revalidate,revalidateTag; la arquitectura híbrida cerrada. - 10-03 · Suspense y RSC: el mecanismo de suspensión, el streaming, la frontera
'use client'y las acciones de servidor. - 10-04 · TypeScript: el análisis estático como capa más barata; el dominio en
tipos/dominio.ts; componentes, hooks, eventos, Redux, Query y validación en el límite con Zod. - 10-05 · React Native: la misma React fuera del navegador.
Y ahora el aviso que ya se dio en 10-02 y en 10-03, ahora en firme:
El Módulo 11 construye el proyecto final con Vite + React Router, la aplicación de gestión de CicloUrbano que llevas montando desde el primer módulo. No con Next.js ni con React Native.
No es una contradicción: es la aplicación del criterio que este módulo te ha dado. El proyecto final es una herramienta de trabajo, privada, tras identificación y con mucho estado interactivo, exactamente la casilla de la tabla de 10-02 donde CSR es la respuesta correcta.
Lo que el proyecto final pondrá en práctica, módulo por módulo:
| Módulo | Qué se aplica en el proyecto |
|---|---|
| 1-2 | Vite, JSX, componentes, props, estado, CSS Modules |
| 3 | Eventos, listas y claves, formularios controlados, validación, accesibilidad |
| 4 | Elevar el estado, composición con children, límites de error |
| 5 | Todos los hooks y los hooks propios de CicloUrbano |
| 6 | React Router con rutas anidadas, protegidas y navegación programática |
| 7 | Contexto, Redux Toolkit y TanStack Query, cada uno en su sitio |
| 8 | memo, useMemo/useCallback, lazy + Suspense, Profiler |
| 9 | Vitest, Testing Library, MSW y Cypress sobre los flujos críticos |
| 10 | El criterio: qué renderizar dónde, y TypeScript si se decide tipar |
Errores Comunes y Consejos
- Poner texto fuera de un
<Text>. Error clásico del primer día:<View>Hola</View>falla. Todo el texto va dentro deText. - Esperar que los estilos se hereden. No hay cascada. Cada componente lleva su
style; la única herencia es la deTextdentro deText. - Olvidar
flexDirection: 'row'. Por defecto se apila en columna. Si algo aparece uno debajo de otro cuando lo querías en línea, es esto. - Usar
ScrollViewcon.map()para listas de datos. Renderiza todo y bloquea la aplicación.FlatListdesde el primer momento. - Escribir
fontWeight: 600. Debe ser la cadena'600'. - Usar
px,removh. Los números van sin unidad.vhyremno existen; el tamaño de pantalla se consulta conuseWindowDimensions(). - Copiar
useAlmacenLocalsin adaptarlo.AsyncStoragees asíncrono: hay que gestionar el estado «aún no cargado». - Pedir todos los permisos al arrancar. Molesta al usuario y es motivo de rechazo en las tiendas. Se piden cuando se necesitan, con contexto.
- Suponer que la aplicación funciona sin permisos. Diseña siempre el camino alternativo cuando se deniegan.
- Probar solo en un simulador de iOS. Los tamaños, los gestos, el botón atrás y el rendimiento son distintos en Android y en dispositivos reales de gama media.
- Consejo: empieza por la lógica compartida. Copia
tipos/, las utilidades puras, los slices y las consultas antes de escribir una sola pantalla. Verás inmediatamente cuánto ya está hecho. - Consejo: piensa en gestos, no en clics. Deslizar, mantener pulsado, tirar para recargar y el botón atrás son parte del lenguaje móvil. Portar la web literalmente produce una aplicación que se siente extranjera.
Ejercicios
Ejercicio 1. Este componente, portado apresuradamente de la web, tiene cinco errores propios de React Native. Encuéntralos, explica cada uno y reescríbelo.
import { View, StyleSheet } from 'react-native';
import EtiquetaEstado from './EtiquetaEstado';
function TarjetaEstacion({ estacion, alPulsar }) {
return (
<View style={estilos.tarjeta} onClick={() => alPulsar(estacion.id)}>
<View style={estilos.cabecera}>
{estacion.nombre}
<EtiquetaEstado estado="disponible" />
</View>
<View style={estilos.detalle}>{estacion.barrio} · {estacion.plazas} plazas</View>
</View>
);
}
const estilos = StyleSheet.create({
tarjeta: { backgroundColor: '#fff', padding: '16px', borderRadius: 12 },
cabecera: { display: 'flex', justifyContent: 'space-between' },
detalle: { fontSize: 14, color: '#6b7785' },
});Ejercicio 2. Escribe PantallaEstaciones para CicloUrbanoMovil: obtiene las estaciones con la consulta de TanStack Query reutilizada del proyecto web, las muestra en una FlatList con «tirar para recargar», trata los estados de carga, error y lista vacía, y navega al detalle al pulsar. Indica además qué ficheros del proyecto web has copiado sin modificar.
Ejercicio 3. El equipo de CicloUrbano quiere que, al abrir la aplicación, se muestre en primer lugar la estación más cercana. Diseña la solución completa: qué permiso hace falta y cuándo pedirlo, qué ocurre si el usuario lo deniega, dónde vive esa lógica para poder probarla, y qué parte se puede reutilizar en la web. Escribe el hook useEstacionMasCercana y explica sus decisiones.
Soluciones
Solución 1. Los cinco errores:
onClickno existe. En React Native esonPress, y unaViewno es pulsable: hay que usarPressableoTouchableOpacity.- Texto suelto dentro de
View.{estacion.nombre}y la línea del barrio deben ir dentro de<Text>. padding: '16px'. Los valores numéricos van sin unidad ni comillas:padding: 16.display: 'flex'sobra, y faltaflexDirection: 'row': por defecto se apila en columna, así que la cabecera no quedaría en línea.- Faltan los tipos (10-04) y el
accessibilityRole/accessibilityLabelpara el lector de pantalla.
import { View, Text, Pressable, StyleSheet } from 'react-native';
import EtiquetaEstado from './EtiquetaEstado';
import type { Estacion } from '../tipos/dominio';
type Props = {
estacion: Estacion;
alPulsar: (estacionId: string) => void;
};
function TarjetaEstacion({ estacion, alPulsar }: Props) {
return (
<Pressable
style={({ pressed }) => [estilos.tarjeta, pressed && estilos.pulsada]}
onPress={() => alPulsar(estacion.id)}
accessibilityRole="button"
accessibilityLabel={`Estación ${estacion.nombre}, barrio ${estacion.barrio}, ${estacion.plazas} plazas`}
>
<View style={estilos.cabecera}>
<Text style={estilos.nombre}>{estacion.nombre}</Text>
<EtiquetaEstado estado="disponible" />
</View>
<Text style={estilos.detalle}>
{estacion.barrio} · {estacion.plazas} plazas
</Text>
</Pressable>
);
}
const estilos = StyleSheet.create({
tarjeta: {
backgroundColor: '#ffffff',
padding: 16,
borderRadius: 12,
borderWidth: 1,
borderColor: '#d9e2ec',
},
pulsada: { opacity: 0.7 },
cabecera: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: 4,
},
nombre: { fontSize: 18, fontWeight: '600', color: '#1f2933' },
detalle: { fontSize: 14, color: '#6b7785' },
});
export default TarjetaEstacion;Solución 2.
// app/(pestanas)/estaciones.tsx
import { View, Text, FlatList, ActivityIndicator, RefreshControl, StyleSheet } from 'react-native';
import { useRouter } from 'expo-router';
import { useEstaciones } from '../../consultas/estaciones'; // copiado del proyecto web
import TarjetaEstacion from '../../componentes/TarjetaEstacion';
import { colores } from '../../tema/colores';
export default function PantallaEstaciones() {
const router = useRouter();
const { data: estaciones, isPending, isError, error, refetch, isRefetching } =
useEstaciones();
if (isPending) {
return <ActivityIndicator style={estilos.centro} size="large" color={colores.marca} />;
}
if (isError) {
return (
<View style={estilos.centro}>
<Text style={estilos.error}>No se han podido cargar las estaciones.</Text>
<Text style={estilos.detalle}>{error.message}</Text>
</View>
);
}
return (
<FlatList
data={estaciones}
keyExtractor={(estacion) => estacion.id}
renderItem={({ item }) => (
<TarjetaEstacion
estacion={item}
alPulsar={(id) => router.push(`/estaciones/${id}`)}
/>
)}
contentContainerStyle={estilos.contenido}
ItemSeparatorComponent={() => <View style={{ height: 12 }} />}
ListEmptyComponent={
<View style={estilos.centro}>
<Text>No hay estaciones registradas.</Text>
</View>
}
refreshControl={
<RefreshControl refreshing={isRefetching} onRefresh={refetch} tintColor={colores.marca} />
}
/>
);
}
const estilos = StyleSheet.create({
contenido: { padding: 16 },
centro: { flex: 1, alignItems: 'center', justifyContent: 'center', padding: 32 },
error: { fontSize: 16, fontWeight: '600', color: colores.mantenimiento, marginBottom: 8 },
detalle: { fontSize: 14, color: '#6b7785' },
});Ficheros copiados sin modificar del proyecto web:
tipos/dominio.ts— las entidades del dominio.tipos/esquemas.ts— los esquemas de Zod, incluida la validación en el límite.consultas/estaciones.ts— la consulta de TanStack Query; solo se ajusta la URL base, porquelocalhostdesde un móvil real apunta al propio móvil (hay que usar la IP de la máquina de desarrollo o una variable de entorno).
Lo único que se ha escrito de nuevo es la capa de presentación: FlatList en lugar de <ul>, ActivityIndicator en lugar de IndicadorDeCarga, y RefreshControl, que no tiene equivalente en la web.
Solución 3.
// hooks/useEstacionMasCercana.ts
import { useState, useEffect } from 'react';
import * as Location from 'expo-location';
import { ordenarPorCercania } from '../utilidades/geografia'; // pura y compartible
import type { Estacion } from '../tipos/dominio';
type Resultado = {
estaciones: Estacion[];
masCercana: Estacion | null;
usandoUbicacion: boolean;
cargando: boolean;
};
export function useEstacionMasCercana(estaciones: Estacion[]): Resultado {
const [ordenadas, setOrdenadas] = useState<Estacion[]>(estaciones);
const [usandoUbicacion, setUsandoUbicacion] = useState(false);
const [cargando, setCargando] = useState(true);
useEffect(() => {
let cancelado = false;
async function calcular() {
try {
const { status } = await Location.requestForegroundPermissionsAsync();
if (status !== 'granted') {
// Camino alternativo: orden alfabético. La app SIGUE funcionando.
if (!cancelado) {
setOrdenadas([...estaciones].sort((a, b) => a.nombre.localeCompare(b.nombre)));
setUsandoUbicacion(false);
}
return;
}
const { coords } = await Location.getCurrentPositionAsync({
accuracy: Location.Accuracy.Balanced, // suficiente y ahorra batería
});
if (!cancelado) {
setOrdenadas(ordenarPorCercania(estaciones, coords.latitude, coords.longitude));
setUsandoUbicacion(true);
}
} finally {
if (!cancelado) setCargando(false);
}
}
calcular();
return () => { cancelado = true; }; // limpieza del módulo 5
}, [estaciones]);
return {
estaciones: ordenadas,
masCercana: ordenadas[0] ?? null,
usandoUbicacion,
cargando,
};
}Las decisiones y su justificación:
- Cuándo pedir el permiso: al entrar en la pantalla de estaciones, no al arrancar la aplicación. El usuario entiende para qué se le pide porque está mirando una lista de estaciones. En producción conviene además una pantalla previa que lo explique antes de lanzar el diálogo del sistema, porque en iOS solo se puede preguntar una vez.
- Si lo deniega: la lista se ordena alfabéticamente y
usandoUbicacionqueda enfalse, para que la interfaz pueda mostrar un aviso discreto con un enlace a los ajustes. La funcionalidad principal no depende del permiso. - Dónde vive la lógica para poder probarla: el cálculo está en
utilidades/geografia.ts, una función puraordenarPorCercania(estaciones, lat, lon). Se prueba con Vitest sin simuladores ni permisos, exactamente comovalidarReservaen 09-02. El hook solo orquesta permiso, obtención y estado. - Qué se reutiliza en la web:
ordenarPorCercaniatal cual, y la estructura del hook casi entera; solo se sustituyeexpo-locationpornavigator.geolocation. Merece la pena extraer unobtenerPosicion()por plataforma y dejar el resto compartido. - La limpieza con
canceladoevita actualizar el estado si el usuario sale de la pantalla mientras se resuelve la ubicación: el patrón de 05-02 aplicado igual.
Conclusión
Con esta lección se cierra el Módulo 10 y, con él, todo el recorrido técnico del curso antes del proyecto.
Lo esencial de React Native es que es React con otro objetivo de renderizado: el mismo reconciliador, los mismos hooks y el mismo modelo declarativo del módulo 1, pero produciendo vistas nativas de verdad en lugar de nodos del DOM. Eso lo separa de la web móvil, de la PWA y de la aplicación híbrida, que simulan los componentes del sistema dentro de un WebView y siempre se sienten como una web. De la arquitectura nueva basta con retener tres nombres —JSI, Fabric y TurboModules— y su consecuencia: menos cuello de botella, arranque más rápido, y los principios de rendimiento del módulo 8 más vigentes que nunca, porque un móvil tiene menos margen.
La frontera es clara: todo lo que es React se comparte; todo lo que es web no. Se van el DOM, las etiquetas HTML, el CSS con su cascada y sus selectores, localStorage y React Router. Se quedan los componentes, las props, la composición, todos los hooks, el contexto, memo, Suspense, los límites de error, Redux Toolkit, TanStack Query y los tipos de 10-04.
En herramientas queda montado CicloUrbanoMovil con Expo (npx create-expo-app, Expo Go, EAS Build y EAS Update), con Expo Router navegando por ficheros igual que el App Router de 10-01 —_layout.tsx, [bicicletaId].tsx, useLocalSearchParams, pilas y pestañas—. La interfaz se escribe con View, Text, Image, TextInput, Pressable, ScrollView, FlatList y SafeAreaView, con dos reglas que hay que interiorizar el primer día: todo el texto va dentro de Text y no hay cascada de estilos. Los estilos son objetos de StyleSheet.create, en camelCase, con números sin unidad, Flexbox por defecto y flexDirection: 'column', sin selectores, sin :hover y sin media queries.
Del porte de CicloUrbano, lo importante es lo poco que cambia: EtiquetaEstado y TarjetaBicicleta conservan la misma estructura de props, los mismos tipos y la misma lógica; solo se reescribe la presentación y las convenciones de la plataforma (onPress, accessibilityLabel). Y ListaBicicletas con FlatList —data, renderItem, keyExtractor— introduce la lección de fondo: FlatList no es un map, sino una lista virtualizada que mantiene en memoria una quincena de elementos en lugar de quinientos. La virtualización que en 08-01 era una técnica avanzada que había que añadir, aquí es el camino por defecto.
Y el balance del que arranca todo lo demás: se copian sin tocar tipos/dominio.ts, los esquemas de Zod, validarReserva, disponibilidad, los slices de Redux, las consultas de TanStack Query y los hooks sin DOM; se adaptan useAlmacenLocal (a AsyncStorage, que es asíncrono), useAnchoVentana y useEstadoConexion; y se reescribe solo la capa de presentación y la navegación. Encima de ese núcleo, el móvil añade lo que la web no puede dar —ubicación, cámara y notificaciones— con la regla que las gobierna a todas: pide el permiso cuando lo necesites, explica para qué, y haz que la aplicación funcione si te dicen que no. Cierran el cuadro las diferencias de plataforma con Platform.select, la accesibilidad móvil con accessibilityLabel y accessibilityRole, las pruebas con Jest y @testing-library/react-native bajo el mismo principio de 09-01, y la publicación con su regla de oro: JavaScript por aire con eas update, código nativo por la tienda.
Con esto se cierra el Módulo 10. Empezaste el módulo con una aplicación que se descargaba al navegador y se ejecutaba allí, y lo terminas sabiendo generar HTML en el servidor y en la construcción, decidir qué estrategia merece cada pantalla, entender dónde se ejecuta cada componente y qué cruza la frontera entre servidor y cliente, convertir contratos implícitos en tipos verificados, y llevar todo ese conocimiento a un móvil. No has aprendido cinco tecnologías sueltas: has aprendido a elegir.
Y ahora toca demostrarlo. El Módulo 11: Proyecto construye la aplicación completa de CicloUrbano de principio a fin, con Vite y React Router —la decisión correcta para una herramienta de gestión privada, interactiva y tras identificación, según el criterio que tú mismo has aplicado en 10-02—. Ahí se juntan los diez módulos: la configuración y la planificación, la interfaz con sus componentes y su accesibilidad, el estado con contexto, Redux Toolkit y TanStack Query, las pruebas en sus cuatro niveles y el despliegue a producción. La próxima lección es Configuración y Planificación del Proyecto.
Curso de React
Módulo 1: Introducción a React
- ¿Qué es React?
- Configuración del Entorno de Desarrollo
- Hola Mundo en React
- JSX: Extensión de Sintaxis de JavaScript
- Cómo Renderiza React: Virtual DOM y Reconciliación
Módulo 2: Componentes de React
- Entendiendo los Componentes
- Componentes Funcionales vs de Clase
- Props: Pasando Datos a Componentes
- State: Gestión del Estado del Componente
- Estilos en los Componentes: CSS, Módulos y Utilidades
Módulo 3: Trabajando con Eventos
- Manejo de Eventos en React
- Renderizado Condicional
- Listas y Claves
- Formularios y Componentes Controlados
- Validación de Formularios y Componentes No Controlados
- Accesibilidad en Componentes Interactivos
Módulo 4: Conceptos Avanzados de Componentes
- Elevando el Estado
- Composición vs Herencia
- Métodos del Ciclo de Vida de React
- Hooks: Introducción y Uso Básico
- Límites de Error: Capturar Fallos en la Interfaz
Módulo 5: Hooks de React
- Hook useState
- Hook useEffect
- Hook useRef y Acceso al DOM
- Hook useContext
- Hook useReducer
- Hooks Personalizados
Módulo 6: Enrutamiento en React
- Introducción a React Router
- Configuración de React Router
- Rutas Anidadas
- Navegación Programática
- Rutas Protegidas y Control de Acceso
Módulo 7: Gestión del Estado
- Introducción a la Gestión del Estado
- API de Contexto
- Redux: Introducción y Configuración
- Redux: Acciones y Reductores
- Redux: Conectando a React
- Estado del Servidor: Peticiones, Caché y Sincronización
Módulo 8: Optimización del Rendimiento
- Técnicas de Optimización del Rendimiento en React
- Memorización con React.memo
- Hooks useMemo y useCallback
- División de Código y Carga Perezosa
- Medir el Rendimiento con React DevTools Profiler
Módulo 9: Pruebas en React
- Introducción a las Pruebas
- Pruebas Unitarias con Jest
- Pruebas de Componentes con React Testing Library
- Pruebas de Código Asíncrono y Simulación de APIs
- Pruebas de Extremo a Extremo con Cypress
Módulo 10: Temas Avanzados
- Renderizado del Lado del Servidor (SSR) con Next.js
- Generación de Sitios Estáticos (SSG) con Next.js
- Suspense y React Server Components
- TypeScript con React
- React Native: Creación de Aplicaciones Móviles
