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

  1. Qué es React Native y en qué se diferencia de las alternativas
  2. Qué se comparte con React y qué no
  3. La arquitectura en una idea
  4. Expo: crear CicloUrbanoMovil
  5. Componentes básicos y su equivalencia con la web
  6. Estilos: StyleSheet y las diferencias con CSS
  7. Portar EtiquetaEstado y TarjetaBicicleta
  8. ListaBicicletas con FlatList
  9. Qué se reutiliza tal cual del proyecto web
  10. Navegación con Expo Router
  11. Capacidades del dispositivo
  12. Diferencias de plataforma y accesibilidad móvil
  13. Depuración y pruebas
  14. Publicación en las tiendas
  15. Cierre del módulo y puente al proyecto final

  1. 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
En las tiendas No Limitado
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í (~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.

  1. 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.

  1. 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.

  1. Expo: crear CicloUrbanoMovil

Se 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.

npx create-expo-app CicloUrbanoMovil
cd CicloUrbanoMovil
npx expo start

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/

  1. 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.

  1. Estilos: StyleSheet y las diferencias con CSS

No 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
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:

<View style={[estilos.tarjeta, esDestacada && estilos.tarjetaDestacada]} />

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.

  1. Portar EtiquetaEstado y TarjetaBicicleta

Aquí 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.

  1. ListaBicicletas con FlatList

Aquí 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:

<ul>
  {bicicletas.map((bici) => <TarjetaBicicleta key={bici.id} bicicleta={bici} />)}
</ul>

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
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.

  1. 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 localStorageAsyncStorage (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.

  1. 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.

  1. 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.json con textos comprensibles: Apple y Google rechazan aplicaciones con justificaciones genéricas, y la ubicación en segundo plano es objeto de revisión especial.

  1. 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—.

  1. 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 m en 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 corre npx 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.

  1. 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 Play

Lo 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:

eas update --branch produccion --message "Corrige el cálculo del precio con descuento"

Los usuarios lo reciben al siguiente arranque. Lo que 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.

  1. 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-web con 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 de Text.
  • Esperar que los estilos se hereden. No hay cascada. Cada componente lleva su style; la única herencia es la de Text dentro de Text.
  • 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 ScrollView con .map() para listas de datos. Renderiza todo y bloquea la aplicación. FlatList desde el primer momento.
  • Escribir fontWeight: 600. Debe ser la cadena '600'.
  • Usar px, rem o vh. Los números van sin unidad. vh y rem no existen; el tamaño de pantalla se consulta con useWindowDimensions().
  • Copiar useAlmacenLocal sin adaptarlo. AsyncStorage es 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:

  1. onClick no existe. En React Native es onPress, y una View no es pulsable: hay que usar Pressable o TouchableOpacity.
  2. Texto suelto dentro de View. {estacion.nombre} y la línea del barrio deben ir dentro de <Text>.
  3. padding: '16px'. Los valores numéricos van sin unidad ni comillas: padding: 16.
  4. display: 'flex' sobra, y falta flexDirection: 'row': por defecto se apila en columna, así que la cabecera no quedaría en línea.
  5. Faltan los tipos (10-04) y el accessibilityRole/accessibilityLabel para 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, porque localhost desde 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 usandoUbicacion queda en false, 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 pura ordenarPorCercania(estaciones, lat, lon). Se prueba con Vitest sin simuladores ni permisos, exactamente como validarReserva en 09-02. El hook solo orquesta permiso, obtención y estado.
  • Qué se reutiliza en la web: ordenarPorCercania tal cual, y la estructura del hook casi entera; solo se sustituye expo-location por navigator.geolocation. Merece la pena extraer un obtenerPosicion() por plataforma y dejar el resto compartido.
  • La limpieza con cancelado evita 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 FlatListdata, 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

Módulo 2: Componentes de React

Módulo 3: Trabajando con Eventos

Módulo 4: Conceptos Avanzados de Componentes

Módulo 5: Hooks de React

Módulo 6: Enrutamiento en React

Módulo 7: Gestión del Estado

Módulo 8: Optimización del Rendimiento

Módulo 9: Pruebas en React

Módulo 10: Temas Avanzados

Módulo 11: Proyecto: Construyendo una Aplicación Completa

© Copyright 2026. Todos los derechos reservados