Tot el que has construït en aquest curs viu dins d'un navegador. Els components produeixen elements del DOM, els estils són CSS, els esdeveniments són esdeveniments del DOM i l'emmagatzematge és localStorage. Aquesta última lliçó del mòdul fa el salt més gran: portar el que has après fora del navegador, a una aplicació mòbil que s'instal·la des d'una botiga i que dibuixa vistes natives de veritat. La bona notícia és que la major part del que saps es transfereix intacte —components, props, estat, hooks, context, Redux, TanStack Query, els tipus de TypeScript de la lliçó anterior—; el que canvia és la capa de presentació i les APIs de plataforma. Muntaràs CicloUrbanoMovil amb Expo, portaràs TargetaBicicleta, EtiquetaEstat i LlistaBicicletes, entendràs per què FlatList no és un map, navegaràs amb Expo Router, i sabràs exactament quin codi del projecte web pots copiar sense tocar ni una línia. L'objectiu no és dominar React Native en una lliçó, sinó entendre el model, els seus límits i què es reutilitza, perquè el salt sigui una decisió informada.

Contingut

  1. Què és React Native i en què es diferencia de les alternatives
  2. Què es comparteix amb React i què no
  3. L'arquitectura en una idea
  4. Expo: crear CicloUrbanoMovil
  5. Components bàsics i la seva equivalència amb la web
  6. Estils: StyleSheet i les diferències amb CSS
  7. Portar EtiquetaEstat i TargetaBicicleta
  8. LlistaBicicletes amb FlatList
  9. Què es reutilitza tal qual del projecte web
  10. Navegació amb Expo Router
  11. Capacitats del dispositiu
  12. Diferències de plataforma i accessibilitat mòbil
  13. Depuració i proves
  14. Publicació a les botigues
  15. Tancament del mòdul i pont al projecte final

  1. Què és React Native i en què es diferencia de les alternatives

React Native és React amb un altre objectiu de renderitzat. El mateix React que coneixes —el mateix reconciliador, els mateixos hooks, la mateixa reconciliació del mòdul 1— però en lloc de produir nodes del DOM produeix vistes natives: un UIView a iOS, un android.view.View a Android.

Això vol dir que un <Text> de React Native no és un <p> disfressat. És un UILabel real, amb el desplaçament del sistema, la selecció del sistema, l'accessibilitat del sistema i el rendiment del sistema.

Comparativa amb les opcions que solen posar-se sobre la taula:

Web mòbil PWA Híbrida (Cordova/Ionic) React Native Nativa (Swift/Kotlin)
Què s'executa HTML/CSS/JS al navegador Igual, amb service worker HTML/CSS/JS en un WebView JS + vistes natives Codi natiu
Interfície Elements del DOM Elements del DOM Elements del DOM Components natius Components natius
Instal·lable No Parcialment
A les botigues No Limitat
Accés al dispositiu Molt limitat Limitat Via plugins Ampli Total
Sensació en usar-la De web De web De web Gairebé nativa Nativa
Un codi, dues plataformes Sí (~85-95 %) No
Corba des de React Cap Baixa Baixa Mitjana Alta

La diferència que decideix entre híbrida i React Native és la fila de la interfície. Una aplicació híbrida simula els components del sistema amb HTML i CSS: el desplaçament no té la mateixa inèrcia, els formularis no es comporten igual i l'usuari ho nota encara que no sàpiga dir per què. React Native fa servir els components de veritat.

I la honestedat sobre el percentatge compartit: el 85-95 % és de la lògica, no de la interfície completa. La navegació, els espaiats, els gestos i diverses convencions difereixen entre iOS i Android, i una aplicació ben feta tracta aquestes diferències en lloc d'ignorar-les.

  1. Què es comparteix amb React i què no

Aquesta taula és el mapa mental de la lliçó.

Es comparteix ✅ No existeix ❌
Components i JSX El DOM (document, window)
Props i composició Les etiquetes HTML (div, p, span, img)
useState, useEffect, useRef, useReducer, useMemo, useCallback CSS: fulls d'estil, cascada, selectors
useContext i l'API de context localStorage, sessionStorage
Hooks personalitzats (si no toquen el DOM) fetch sí que existeix; XMLHttpRequest també
memo, lazy, Suspense, límits d'error React Router (s'utilitza Expo Router)
Redux Toolkit i react-redux CSS Modules
TanStack Query alert, prompt (hi ha Alert de React Native)
Tipus de TypeScript Elements SVG en línia (hi ha react-native-svg)
Lògica de validació i utilitats pures Etiquetes semàntiques (nav, header, main)

El que importa d'aquesta taula és on és la frontera: tot el que és React es comparteix; tot el que és web no. La distinció que a 10-03 separava servidor de client aquí separa lògica de presentació, i és igual d'útil per decidir on va cada cosa.

  1. L'arquitectura en una idea

Com funciona per dins, amb el nivell de detall que cal:

flowchart TB
    subgraph JS["Fil de JavaScript"]
        A["Els teus components de React"] --> B["React · reconciliacio"]
        B --> C["Descripcio de l'arbre de vistes"]
    end

    C -->|"JSI · crides directes"| D

    subgraph NATIU["Fil d'interficie natiu"]
        D["Fabric · renderitzador"] --> E["UIView (iOS)<br/>android.view.View (Android)"]
        E --> F["Pantalla"]
    end

    F -->|"tocs i gestos"| A

La idea en una frase: els teus components descriuen la interfície i el sistema pinta vistes natives de veritat. Tu no manipules vistes; descrius com ha de veure's l'estat actual i React s'encarrega de la resta. És exactament el model declaratiu del mòdul 1, amb una altra destinació.

Sobre la arquitectura nova (activada per defecte des de React Native 0.76), n'hi ha prou de saber tres noms i què resolen:

  • JSI (JavaScript Interface): permet que JavaScript cridi el codi natiu directament, sense serialitzar missatges JSON a través d'un pont asíncron. Elimina el coll d'ampolla històric de React Native.
  • Fabric: el renderitzador nou, que permet operacions síncrones i fa possible que la interfície respongui en el mateix fotograma.
  • TurboModules: els mòduls natius es carreguen quan s'utilitzen, no tots en arrencar, la qual cosa redueix el temps d'inici.

No cal res més per treballar-hi. El que és rellevant a la pràctica és que els problemes de rendiment clàssics de React Native —llistes que van a batzegades, animacions que s'encallen— han millorat molt, i que els principis del mòdul 8 (evitar renders innecessaris, memoïtzar, virtualitzar) continuen sent vàlids i aquí importen encara més, perquè un mòbil té menys marge que un portàtil.

  1. Expo: crear CicloUrbanoMovil

Es pot fer servir React Native «nu» amb la CLI oficial, però això obliga a tenir Xcode i Android Studio configurats des del primer minut, i a gestionar a mà la configuració nativa. Expo és la forma recomanada de començar, i la que recomana la documentació oficial de React Native.

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

L'última comanda mostra un codi QR al terminal. Instal·les Expo Go al teu mòbil, escanejes el codi i l'aplicació s'obre al dispositiu real, amb recàrrega en calent en guardar. Sense cables, sense signar res, sense Xcode.

Què aporta Expo:

Peça Per a què serveix
Expo Go Executar l'aplicació en un mòbil real sense compilar
SDK d'Expo Biblioteques per a càmera, ubicació, notificacions, fitxers, sensors… ja integrades
Expo Router Navegació per fitxers, com l'App Router de 10-01
EAS Build Compilar els binaris d'iOS i Android al núvol, sense Mac
EAS Update Publicar canvis de JavaScript per aire, sense passar per revisió
Mòduls de desenvolupament Compilar una versió pròpia quan necessites codi natiu que Expo Go no inclou

Estructura del projecte acabat de crear:

CicloUrbanoMovil/
├── app/                    ← rutes per fitxers (Expo Router)
│   ├── _layout.tsx         ← plantilla arrel
│   ├── index.tsx           ← pantalla inicial "/"
│   └── +not-found.tsx
├── components/
├── assets/                 ← imatges i fonts
├── app.json                ← configuració: nom, icona, permisos
├── tsconfig.json           ← TypeScript ve configurat de fàbrica
└── package.json

Reconeixeràs el patró d'app/: és el mateix enrutament per carpetes de Next.js. I fixa't que les plantilles d'Expo vénen amb TypeScript ja configurat, així que tot el de 10-04 aplica directament.

Adaptarem l'estructura a les convencions del curs:

CicloUrbanoMovil/
├── app/
│   ├── _layout.tsx
│   ├── (pestanas)/
│   │   ├── _layout.tsx
│   │   ├── index.tsx           → catàleg
│   │   ├── estaciones.tsx
│   │   └── reservas.tsx
│   └── bicicletas/[bicicletaId].tsx
├── components/
├── hooks/
├── utilitats/
├── tipus/                      ← copiat del projecte web
└── consultes/

  1. Components bàsics i la seva equivalència amb la web

No hi ha etiquetes HTML: hi ha components importats de react-native.

React Native Equivalent web Notes
<View> <div> Contenidor. No pot contenir text solt
<Text> <p>, <span>, <h1> Tot el text va dins d'un Text, sense excepció
<Image> <img> source={{ uri }} o require() per a local
<TextInput> <input>, <textarea> onChangeText dona el text directament
<Pressable> <button>, <a> L'estàndard actual per a qualsevol zona polsable
<TouchableOpacity> <button> Alternativa amb retroalimentació d'opacitat
<ScrollView> <div style="overflow:scroll"> Renderitza tots els fills alhora
<FlatList> Llista amb .map() Virtualitzada: només renderitza el que és visible
<SectionList> Llista agrupada Amb capçaleres de secció
<SafeAreaView> Evita la retallada, la barra d'estat i l'indicador inferior
<Modal> <dialog> Superposició nativa
<ActivityIndicator> Un spinner propi L'indicador de càrrega del sistema
<Switch> <input type="checkbox"> Interruptor natiu

La regla que més s'oblida en començar, i mereix destacar-se:

// ❌ ERROR: "Text strings must be rendered within a <Text> component"
<View>Elèctrica Pro</View>

// ✅ CORRECTE
<View>
  <Text>Elèctrica Pro</Text>
</View>

A la web, qualsevol element pot contenir text. A React Native, només <Text> pot. La raó és que el text es mesura i es pinta amb el motor tipogràfic natiu, que és un component diferent d'una vista contenidora. En canvi, <Text> sí que es pot imbricar i hereta estils, que és l'única herència d'estils que existeix a React Native:

<Text style={{ fontSize: 16, color: '#1f2933' }}>
  Preu: <Text style={{ fontWeight: '700' }}>4,00 €/h</Text>
</Text>

I pel que fa a allò polsable, la recomanació actual és Pressable, que dona control fi sobre els estats:

<Pressable
  onPress={() => console.log('polsat')}
  onLongPress={() => console.log('polsada llarga')}
  style={({ pressed }) => [estils.targeta, pressed && estils.targetaPolsada]}
  accessibilityRole="button"
  accessibilityLabel="Veure fitxa d'Elèctrica Pro"
>
  <Text>Veure fitxa</Text>
</Pressable>

Fixa't en style com a funció que rep { pressed }: és el substitut de :active de CSS, i és una bona mostra de com es resolen aquí els estats visuals.

  1. Estils: StyleSheet i les diferències amb CSS

No hi ha CSS. Els estils són objectes de JavaScript, i s'agrupen amb StyleSheet.create:

import { StyleSheet } from 'react-native';

const estils = StyleSheet.create({
  targeta: {
    backgroundColor: '#ffffff',
    borderRadius: 12,
    padding: 16,
    marginBottom: 12,
    borderWidth: 1,
    borderColor: '#d9e2ec',
  },
  model: {
    fontSize: 18,
    fontWeight: '600',
    color: '#1f2933',
    marginBottom: 4,
  },
  preu: {
    fontSize: 16,
    color: '#12805c',
  },
});

Diferències respecte a CSS, que són moltes i convé tenir presents:

CSS (web) React Native
background-color: #fff; backgroundColor: '#ffffff' (camelCase)
padding: 16px; padding: 16 (números sense unitat, en punts independents de la densitat)
font-weight: 600; fontWeight: '600' (cadena)
display: flex; Implícit: tot és Flexbox
flex-direction: row; per defecte flexDirection: 'column' per defecte
Cascada i herència No existeix (excepte text imbricat dins de Text)
Selectors (.classe, :hover) No existeixen: l'estil es passa per prop
Media queries useWindowDimensions() o Platform
:hover, :focus, :active Funció de style amb { pressed }, o estat propi
box-shadow shadowColor/shadowOffset/shadowOpacity (iOS) + elevation (Android)
Unitats %, vh, rem % en alguns casos; res de vh ni rem
position: fixed No existeix; absolute i relative
gap Sí, suportat

Les dues diferències que més desconcerten al principi:

flexDirection: 'column' per defecte. En CSS, un contenidor flex apila en fila; aquí apila en columna, que és el que és habitual en una pantalla vertical. Si alguna cosa apareix apilada quan l'esperaves en línia, és per això.

No hi ha cascada. Un estil posat a la View pare no l'hereten els fills. Cada component porta el seu. Pot semblar un retrocés, però elimina d'un cop la classe d'errors més freqüent en CSS: l'estil que arriba d'un ancestre que ningú recorda. És la mateixa filosofia dels CSS Modules del mòdul 2, portada a l'extrem.

Els estils es combinen passant un array, i guanya l'últim:

<View style={[estils.targeta, esDestacada && estils.targetaDestacada]} />

I les variables de tema de CicloUrbano es traslladen a un mòdul de constants, ja que no hi ha :root:

// tema/colors.ts
export const colors = {
  marca: '#12805c',
  llogada: '#b45309',
  manteniment: '#9b1c1c',
  fons: '#f5f7fa',
  superficie: '#ffffff',
  text: '#1f2933',
  vora: '#d9e2ec',
} as const;

El as const de 10-04 fa que cada valor sigui un literal, la qual cosa dona autocompletat exacte en utilitzar-lo.

  1. Portar EtiquetaEstat i TargetaBicicleta

Aquí es veu el que de veritat canvia. L'estructura de props i la lògica es mantenen idèntiques; només canvia la capa de presentació.

// components/EtiquetaEstat.tsx
import { View, Text, StyleSheet } from 'react-native';
import type { EstatBicicleta } from '../tipus/domini';
import { colors } from '../tema/colors';

type Props = {
  estat: EstatBicicleta;
};

const TEXTOS: Record<EstatBicicleta, string> = {
  disponible: 'Disponible',
  alquilada: 'Llogada',
  mantenimiento: 'En manteniment',
};

const FONS: Record<EstatBicicleta, string> = {
  disponible: colors.marca,
  alquilada: colors.llogada,
  mantenimiento: colors.manteniment,
};

function EtiquetaEstat({ estat }: Props) {
  return (
    <View style={[estils.etiqueta, { backgroundColor: FONS[estat] }]}>
      <Text style={estils.text}>{TEXTOS[estat]}</Text>
    </View>
  );
}

const estils = StyleSheet.create({
  etiqueta: {
    alignSelf: 'flex-start',
    paddingHorizontal: 10,
    paddingVertical: 4,
    borderRadius: 999,
  },
  text: {
    color: '#ffffff',
    fontSize: 12,
    fontWeight: '600',
  },
});

export default EtiquetaEstat;

Compara-ho amb la versió web de 10-04: el tipus Props, l'objecte TEXTOS i la signatura són exactament els mateixos. L'únic que canvia és <span><View> + <Text>, i estils.etiqueta de CSS Modules → StyleSheet. Un Record<EstatBicicleta, string> continua obligant a cobrir els tres estats.

// components/TargetaBicicleta.tsx
import { View, Text, Pressable, StyleSheet } from 'react-native';
import { useRouter } from 'expo-router';
import EtiquetaEstat from './EtiquetaEstat';
import type { Bicicleta } from '../tipus/domini';
import { colors } from '../tema/colors';

type Props = {
  bicicleta: Bicicleta;
  alSeleccionar?: (bicicletaId: string) => void;
};

function TargetaBicicleta({ bicicleta, alSeleccionar }: Props) {
  const router = useRouter();

  function gestionarPolsacio() {
    if (alSeleccionar) alSeleccionar(bicicleta.id);
    else router.push(`/bicicletas/${bicicleta.id}`);
  }

  return (
    <Pressable
      onPress={gestionarPolsacio}
      style={({ pressed }) => [estils.targeta, pressed && estils.polsada]}
      accessibilityRole="button"
      accessibilityLabel={`${bicicleta.model}, ${bicicleta.estat}, ${bicicleta.preuHora} euros per hora`}
    >
      <View style={estils.capcalera}>
        <Text style={estils.model}>{bicicleta.model}</Text>
        <EtiquetaEstat estat={bicicleta.estat} />
      </View>
      <Text style={estils.detall}>{bicicleta.tipus}</Text>
      <Text style={estils.preu}>{bicicleta.preuHora.toFixed(2)} €/h</Text>
    </Pressable>
  );
}

const estils = StyleSheet.create({
  targeta: {
    backgroundColor: colors.superficie,
    borderRadius: 12,
    borderWidth: 1,
    borderColor: colors.vora,
    padding: 16,
    marginBottom: 12,
  },
  polsada: { opacity: 0.7 },
  capcalera: {
    flexDirection: 'row',            // en vertical per defecte: cal demanar-ho
    justifyContent: 'space-between',
    alignItems: 'center',
    marginBottom: 8,
  },
  model: { fontSize: 18, fontWeight: '600', color: colors.text },
  detall: { fontSize: 14, color: '#6b7785', marginBottom: 4 },
  preu: { fontSize: 16, fontWeight: '600', color: colors.marca },
});

export default TargetaBicicleta;

La convenció de props del curs es respecta al peu de la lletra: gestor intern gestionarPolsacio, prop cap enfora alSeleccionar. L'únic específic de la plataforma és Pressable en lloc de <Link>, accessibilityLabel en lloc de aria-label, i flexDirection: 'row' explícit a la capçalera.

  1. LlistaBicicletes amb FlatList

Aquí hi ha la diferència conceptual més important de la lliçó, i enllaça directament amb la virtualització de 08-01.

A la web escrivies això:

<ul>
  {bicicletes.map((bici) => <TargetaBicicleta key={bici.id} bicicleta={bici} />)}
</ul>

Amb 5 bicicletes funciona. Amb 500 en un mòbil, l'aplicació es bloqueja: es creen 500 vistes natives de cop, es consumeix memòria i el fil d'interfície es queda sense marge. Un <ScrollView> amb map a dins té exactament el mateix problema, perquè també renderitza tots els seus fills.

FlatList és una llista virtualitzada: només manté en memòria els elements visibles i uns pocs al voltant, i reutilitza les vistes en desplaçar-se.

// components/LlistaBicicletes.tsx
import { FlatList, View, Text, StyleSheet, RefreshControl } from 'react-native';
import TargetaBicicleta from './TargetaBicicleta';
import type { Bicicleta } from '../tipus/domini';

type Props = {
  bicicletes: Bicicleta[];
  recarregant?: boolean;
  alRecarregar?: () => void;
};

function LlistaBicicletes({ bicicletes, recarregant = false, alRecarregar }: Props) {
  return (
    <FlatList
      data={bicicletes}
      keyExtractor={(bicicleta) => bicicleta.id}
      renderItem={({ item }) => <TargetaBicicleta bicicleta={item} />}
      contentContainerStyle={estils.contingut}
      ListEmptyComponent={
        <View style={estils.buit}>
          <Text>No hi ha bicicletes amb aquest filtre.</Text>
        </View>
      }
      ListHeaderComponent={<Text style={estils.titol}>Catàleg</Text>}
      ItemSeparatorComponent={() => <View style={estils.separador} />}
      refreshControl={
        alRecarregar
          ? <RefreshControl refreshing={recarregant} onRefresh={alRecarregar} />
          : undefined
      }
      initialNumToRender={8}
      removeClippedSubviews
    />
  );
}

const estils = StyleSheet.create({
  contingut: { padding: 16 },
  titol: { fontSize: 24, fontWeight: '700', marginBottom: 16 },
  buit: { paddingVertical: 48, alignItems: 'center' },
  separador: { height: 4 },
});

export default LlistaBicicletes;

Les props essencials:

Prop Què fa
data L'array d'elements
renderItem Funció que rep { item, index } i retorna l'element
keyExtractor La clau estable de cada element (la key del mòdul 3)
ListEmptyComponent Què mostrar amb la llista buida
ListHeaderComponent / ListFooterComponent Capçalera i peu que es desplacen amb la llista
ItemSeparatorComponent Separador entre elements
onEndReached Es dispara en acostar-se al final: paginació infinita
refreshControl El gest d'«estirar per recarregar», natiu
initialNumToRender Quants elements pintar en el primer fotograma

Per què FlatList no és un map, en taula:

.map() a ScrollView FlatList
Vistes creades amb 500 elements 500 ~15
Memòria Creix amb la llista Constant
Temps del primer pintat Creix amb la llista Constant
Reutilitza vistes en desplaçar-se No
Quan utilitzar-lo Llistes curtes i fixes (< 20) Qualsevol llista amb dades

I aquí connecta el mòdul 8 de forma directa: la virtualització que allà es plantejava com una tècnica avançada que calia afegir amb una biblioteca, a React Native ve de sèrie i és el camí per defecte. La raó és el context: en un mòbil, aquest marge no és opcional.

Un consell de rendiment que també ve d'allà: embolcalla TargetaBicicleta amb memo i defineix renderItem amb useCallback. En desplaçar-se, FlatList torna a renderitzar amb freqüència, i evitar renders innecessaris es nota en la fluïdesa de forma molt més visible que a la web.

  1. Què es reutilitza tal qual del projecte web

Aquesta és la pregunta pràctica: quant codi es copia sense tocar.

Del projecte web Es reutilitza? Detall
tipus/domini.ts Tal qual No depèn de res
utilitats/validarReserva.ts Tal qual Funció pura
utilitats/disponibilitat.ts Tal qual Funció pura
utilitats/classes.js Uneix classes CSS; aquí no aplica
Esquemes de Zod Tal qual Validació al límit, igual que a 10-04
magatzem/ i els slices de Redux Tal qual Redux Toolkit no sap res del DOM
consultes/ de TanStack Query Gairebé tal qual fetch existeix; només canvia la URL base
hooks/useAlternar Tal qual Només useState
hooks/useDebounce, useThrottle Tal qual Només temporitzadors
hooks/useValorPrevi Tal qual Només useRef
hooks/useCatalegFiltrat Tal qual Lògica pura
hooks/useMagatzemLocal ⚠️ Adaptar localStorageAsyncStorage (i passa a ser asíncron)
hooks/useAmpladaFinestra ⚠️ Adaptar useWindowDimensions() de React Native
hooks/useEsdevenimentTeclat No hi ha teclat físic com a esdeveniment global
hooks/useEstatConnexio ⚠️ Adaptar @react-native-community/netinfo
contextos/ (tema, avisos) La lògica sí La presentació es reescriu
Components (TargetaBicicleta, etc.) Reescriure La capa de presentació és diferent
rutes.jsx → Expo Router
flowchart TB
    subgraph COMPARTIT["Nucli compartit · es copia sense canvis"]
        T["tipus/domini.ts"]
        U["utilitats: validarReserva, disponibilitat"]
        Z["esquemes de Zod"]
        R["magatzem: sliceSessio, sliceCataleg, sliceReserves"]
        Q["consultes de TanStack Query"]
        H["hooks sense DOM: useAlternar, useDebounce, useValorPrevi"]
    end

    COMPARTIT --> WEB
    COMPARTIT --> MOVIL

    subgraph WEB["ciclourbano-web / SPA Vite"]
        W1["Components amb div, span, CSS Modules"]
        W2["React Router / App Router"]
    end

    subgraph MOVIL["CicloUrbanoMovil"]
        M1["Components amb View, Text, StyleSheet"]
        M2["Expo Router"]
    end

    API[("API comuna")]
    WEB --> API
    MOVIL --> API

En un equip que manté les dues aplicacions, aquest nucli compartit s'extreu a un paquet (@ciclourbano/nucli) dins d'un monorepo. I fixa't en la conseqüència pràctica de 10-04: tipus/domini.ts és el primer que es comparteix, perquè els tipus no tenen dependències i ara els dos projectes parlen del mateix domini verificat.

La substitució més habitual, useMagatzemLocal:

// hooks/useMagatzemLocal.ts (versió mòbil)
import { useState, useEffect } from 'react';
import AsyncStorage from '@react-native-async-storage/async-storage';

export function useMagatzemLocal<T>(clau: string, valorInicial: T) {
  const [valor, setValor] = useState<T>(valorInicial);
  const [carregat, setCarregat] = useState(false);

  // AsyncStorage és ASÍNCRON: no es pot llegir a l'inicialitzador de useState.
  useEffect(() => {
    AsyncStorage.getItem(clau).then((desat) => {
      if (desat !== null) setValor(JSON.parse(desat) as T);
      setCarregat(true);
    });
  }, [clau]);

  useEffect(() => {
    if (carregat) AsyncStorage.setItem(clau, JSON.stringify(valor));
  }, [clau, valor, carregat]);

  return [valor, setValor, carregat] as const;
}

La diferència clau: localStorage és síncron i AsyncStorage no, així que apareix un tercer valor, carregat, que indica si ja s'ha llegit la preferència. És el mateix problema del tema fosc que vas tractar a 10-01 amb la hidratació, amb una altra causa.

  1. Navegació amb Expo Router

Expo Router utilitza enrutament per fitxers, exactament com l'App Router de 10-01. Si vas entendre allò, això ja ho saps.

app/
├── _layout.tsx                    → plantilla arrel
├── (pestanas)/
│   ├── _layout.tsx                → navegador de pestanyes
│   ├── index.tsx                  → "/" catàleg
│   ├── estaciones.tsx             → "/estaciones"
│   └── reservas.tsx               → "/reservas"
├── bicicletas/
│   └── [bicicletaId].tsx          → "/bicicletas/bici-002"
└── +not-found.tsx                 → ruta desconeguda

Equivalències amb React Router, que és el que ja coneixes del mòdul 6:

React Router Expo Router
createBrowserRouter([...]) L'arbre d'app/
<Route path="/estaciones"> app/estaciones.tsx
<Route path="/bicicletas/:bicicletaId"> app/bicicletas/[bicicletaId].tsx
<Outlet /> <Stack />, <Tabs /> o <Slot /> a _layout.tsx
<Link to="/estaciones"> <Link href="/estaciones"> de expo-router
useNavigate() useRouter()
useParams() useLocalSearchParams()
useSearchParams() useLocalSearchParams()
Ruta comodí * +not-found.tsx
RutaProtegida Redirecció al _layout o <Redirect />

La plantilla de pestanyes:

// app/(pestanas)/_layout.tsx
import { Tabs } from 'expo-router';
import { colors } from '../../tema/colors';

export default function PlantillaPestanyes() {
  return (
    <Tabs
      screenOptions={{
        tabBarActiveTintColor: colors.marca,
        headerStyle: { backgroundColor: colors.superficie },
      }}
    >
      <Tabs.Screen name="index" options={{ title: 'Catàleg' }} />
      <Tabs.Screen name="estaciones" options={{ title: 'Estacions' }} />
      <Tabs.Screen name="reservas" options={{ title: 'Les meves reserves' }} />
    </Tabs>
  );
}

I una pantalla de detall amb paràmetre dinàmic:

// app/bicicletas/[bicicletaId].tsx
import { View, Text, ActivityIndicator, StyleSheet } from 'react-native';
import { useLocalSearchParams, Stack } from 'expo-router';
import { useBicicleta } from '../../consultes/bicicletes';
import EtiquetaEstat from '../../components/EtiquetaEstat';

export default function PantallaFitxaBicicleta() {
  const { bicicletaId } = useLocalSearchParams<{ bicicletaId: string }>();
  const { data: bicicleta, isPending, isError } = useBicicleta(bicicletaId);

  if (isPending) return <ActivityIndicator style={estils.centre} size="large" />;
  if (isError || !bicicleta) return <Text style={estils.centre}>No trobada.</Text>;

  return (
    <View style={estils.contenidor}>
      {/* Configura la capçalera de la pila des de la mateixa pantalla */}
      <Stack.Screen options={{ title: bicicleta.model }} />
      <Text style={estils.titol}>{bicicleta.model}</Text>
      <EtiquetaEstat estat={bicicleta.estat} />
      <Text style={estils.preu}>{bicicleta.preuHora.toFixed(2)} €/h</Text>
    </View>
  );
}

const estils = StyleSheet.create({
  contenidor: { flex: 1, padding: 16 },
  centre: { flex: 1, textAlign: 'center', marginTop: 48 },
  titol: { fontSize: 24, fontWeight: '700', marginBottom: 8 },
  preu: { fontSize: 18, color: '#12805c', marginTop: 8 },
});

Dos conceptes de navegació mòbil que no tenen equivalent web:

  • Pila (stack): les pantalles s'apilen i es torna enrere amb el gest o el botó físic d'Android. L'animació d'entrada pel lateral és part de la convenció de la plataforma.
  • Pestanyes (tabs): la barra inferior amb les seccions principals. Cada pestanya conserva la seva pròpia pila i el seu estat en canviar d'una a una altra, una cosa que a la web hauria requerit treball explícit.

I fixa't que useBicicleta és la mateixa consulta de TanStack Query del projecte web: la capa de dades es reutilitza intacta.

  1. Capacitats del dispositiu

És la raó principal per la qual una empresa com CicloUrbano voldria una aplicació mòbil i no només una web. Tres exemples amb sentit de producte:

Ubicació: trobar l'estació més propera.

import * as Location from 'expo-location';

export async function obtenirEstacioMesPropera(estacions: Estacio[]) {
  const { status } = await Location.requestForegroundPermissionsAsync();
  if (status !== 'granted') {
    throw new Error("Permís d'ubicació denegat");
  }

  const posicio = await Location.getCurrentPositionAsync({});
  const { latitude, longitude } = posicio.coords;

  return estacions
    .map((estacio) => ({
      estacio,
      distancia: calcularDistancia(latitude, longitude, estacio.lat, estacio.lon),
    }))
    .sort((a, b) => a.distancia - b.distancia)[0]?.estacio;
}

Càmera: llegir el codi QR de la bicicleta per desbloquejar-la.

import { CameraView, useCameraPermissions } from 'expo-camera';

function EscanerQR({ alEscanejar }: { alEscanejar: (codi: string) => void }) {
  const [permis, demanarPermis] = useCameraPermissions();

  if (!permis?.granted) {
    return <Button title="Permetre càmera" onPress={demanarPermis} />;
  }

  return (
    <CameraView
      style={{ flex: 1 }}
      barcodeScannerSettings={{ barcodeTypes: ['qr'] }}
      onBarcodeScanned={({ data }) => alEscanejar(data)}
    />
  );
}

Notificacions: avisar que la reserva acaba d'aquí a 10 minuts.

import * as Notifications from 'expo-notifications';

export async function programarAvisFiReserva(reserva: Reserva) {
  const { status } = await Notifications.requestPermissionsAsync();
  if (status !== 'granted') return;

  const final = new Date(reserva.dataInici).getTime() + reserva.hores * 3_600_000;
  const avis = new Date(final - 10 * 60_000);

  await Notifications.scheduleNotificationAsync({
    content: {
      title: 'La teva reserva acaba aviat',
      body: 'Queden 10 minuts. Retorna la bicicleta a una estació per no pagar de més.',
    },
    trigger: { type: 'date', date: avis },
  });
}

Advertència sobre permisos i privacitat. Cadascuna d'aquestes capacitats exigeix el permís explícit de l'usuari, i es pot denegar. Tres regles que no són opcionals: (1) demana el permís quan el necessitis, no en arrencar, i explica abans per a què; (2) l'aplicació ha de funcionar sense ell —si deneguen la ubicació, mostra la llista d'estacions ordenada alfabèticament—; (3) declara els usos a app.json amb textos comprensibles: Apple i Google rebutgen aplicacions amb justificacions genèriques, i la ubicació en segon pla és objecte de revisió especial.

  1. Diferències de plataforma i accessibilitat mòbil

iOS i Android no són iguals, i una aplicació ben feta ho reconeix en lloc de forçar un disseny únic.

import { Platform, StyleSheet } from 'react-native';

const estils = StyleSheet.create({
  targeta: {
    backgroundColor: '#ffffff',
    borderRadius: 12,
    ...Platform.select({
      ios: {
        shadowColor: '#000',
        shadowOffset: { width: 0, height: 2 },
        shadowOpacity: 0.1,
        shadowRadius: 4,
      },
      android: {
        elevation: 3,          // Android utilitza el seu propi sistema d'elevació
      },
    }),
  },
  capcalera: {
    paddingTop: Platform.OS === 'ios' ? 44 : 24,
  },
});

Diferències que més es noten:

Aspecte iOS Android
Ombres shadow* elevation
Botó enrere físic No existeix Sí: cal tractar-lo
Tipografia per defecte San Francisco Roboto
Tornar enrere Gest des de la vora Botó o gest
Fonts Es carreguen amb expo-font Igual
Permisos Diàleg del sistema, una sola vegada Es pot tornar a demanar

Accessibilitat. Els conceptes del mòdul 3 es traslladen amb altres noms, i en mòbil importen igual o més perquè VoiceOver (iOS) i TalkBack (Android) són d'ús molt estès:

Web (mòdul 3) React Native
aria-label accessibilityLabel
role="button" accessibilityRole="button"
aria-disabled accessibilityState={{ disabled: true }}
aria-live="polite" accessibilityLiveRegion="polite"
Ordre de tabulació Ordre natural de l'arbre
Focus visible Menys rellevant: el lector ho anuncia

Dues regles pròpies del mòbil: la zona polsable mínima és d'uns 44×44 punts (fes servir hitSlop si l'element visual és més petit), i el contrast ha de continuar complint la ràtio 4,5:1 —els colors de CicloUrbano ja la compleixen—.

  1. Depuració i proves

Breument, perquè els conceptes ja els tens del mòdul 9.

Depuració:

  • Recàrrega ràpida (Fast Refresh): activa per defecte, conserva l'estat en guardar.
  • Menú de desenvolupament: agitant el dispositiu o amb m al terminal d'Expo.
  • React DevTools: funcionen igual, inclòs el Profiler de 08-05.
  • Depurador d'Expo: inspector de xarxa, consola i punts d'interrupció de JavaScript.
  • console.log: apareix al terminal on s'executa npx expo start.

Proves, amb les mateixes eines del mòdul 9 i equivalents:

Nivell Web (mòdul 9) React Native
Unitàries Vitest / Jest Jest (configurat per Expo)
Components React Testing Library @testing-library/react-native
Simulació de xarxa MSW MSW (compatible)
Extrem a extrem Cypress Maestro o Detox

L'API de Testing Library és gairebé idèntica, i el principi rector de 09-01 —provar el comportament, no la implementació— es manté sense canvis:

import { render, screen, fireEvent } from '@testing-library/react-native';
import TargetaBicicleta from '../components/TargetaBicicleta';

const BICI = {
  id: 'bici-002', model: 'Elèctrica Pro', tipus: 'electrica',
  estat: 'alquilada', estacioId: 'est-01', preuHora: 4.0,
} as const;

test('mostra el model i avisa en polsar', () => {
  const alSeleccionar = jest.fn();
  render(<TargetaBicicleta bicicleta={BICI} alSeleccionar={alSeleccionar} />);

  expect(screen.getByText('Elèctrica Pro')).toBeTruthy();

  fireEvent.press(screen.getByRole('button'));
  expect(alSeleccionar).toHaveBeenCalledWith('bici-002');
});

La diferència visible: fireEvent.press en lloc de click, i getByText retorna un node de React Native en lloc d'un element del DOM. Les consultes per rol continuen sent la forma preferida, igual que a 09-03, i pel mateix motiu: si l'element no té rol, tampoc el té per al lector de pantalla.

  1. Publicació a les botigues

Un resum del que implica, perquè canvia la forma de planificar els lliuraments.

npm install -g eas-cli
eas build --platform all       # compila iOS i Android al núvol
eas submit --platform all      # envia a App Store i Google Play

El que cal saber abans de comprometre dates:

Aspecte App Store (Apple) Google Play
Compte de desenvolupador 99 $/any 25 $ una vegada
Revisió de la primera versió Dies Hores o dies
Revisió d'actualitzacions Hores o dies Normalment hores
Rebuigs freqüents Permisos mal justificats, poc contingut Polítiques de privacitat, permisos
Cal un Mac Sí per compilar en local; no amb EAS Build No

I la peça que canvia el ritme de treball: les actualitzacions per aire. Amb eas update, un canvi que només toqui JavaScript —una correcció de text, un ajust d'estil, un error de lògica— es publica sense passar per revisió:

eas update --branch produccio --message "Corregeix el càlcul del preu amb descompte"

Els usuaris el reben en el següent arrencament. El que que requereix una versió nova i la seva revisió és qualsevol canvi en codi natiu: una biblioteca nova amb part nativa, un permís nou, un canvi d'icona o de nom. La regla, que convé tenir escrita a l'equip: JavaScript per aire, codi natiu per la botiga.

  1. Tancament del mòdul i pont al projecte final

El Mòdul 10 ha portat React a cinc terrenys que no existien en començar:

  • 10-01 · SSR amb Next.js: l'HTML arriba construït; les quatre estratègies de renderitzat; la hidratació i les seves discrepàncies; ciclourbano-web amb App Router.
  • 10-02 · SSG i ISR: generar una vegada i revalidar; generateStaticParams, revalidate, revalidateTag; l'arquitectura híbrida tancada.
  • 10-03 · Suspense i RSC: el mecanisme de suspensió, el streaming, la frontera 'use client' i les accions de servidor.
  • 10-04 · TypeScript: l'anàlisi estàtica com a capa més barata; el domini a tipus/domini.ts; components, hooks, esdeveniments, Redux, Query i validació al límit amb Zod.
  • 10-05 · React Native: el mateix React fora del navegador.

I ara l'avís que ja es va donar a 10-02 i a 10-03, ara en ferm:

El Mòdul 11 construeix el projecte final amb Vite + React Router, l'aplicació de gestió de CicloUrbano que portes muntant des del primer mòdul. No amb Next.js ni amb React Native.

No és una contradicció: és l'aplicació del criteri que aquest mòdul t'ha donat. El projecte final és una eina de treball, privada, després d'identificació i amb molt estat interactiu, exactament la casella de la taula de 10-02 on CSR és la resposta correcta.

El que el projecte final posarà en pràctica, mòdul per mòdul:

Mòdul Què s'aplica al projecte
1-2 Vite, JSX, components, props, estat, CSS Modules
3 Esdeveniments, llistes i claus, formularis controlats, validació, accessibilitat
4 Elevar l'estat, composició amb children, límits d'error
5 Tots els hooks i els hooks propis de CicloUrbano
6 React Router amb rutes imbricades, protegides i navegació programàtica
7 Context, Redux Toolkit i TanStack Query, cadascun al seu lloc
8 memo, useMemo/useCallback, lazy + Suspense, Profiler
9 Vitest, Testing Library, MSW i Cypress sobre els fluxos crítics
10 El criteri: què renderitzar on, i TypeScript si es decideix tipar

Errors Comuns i Consells

  • Posar text fora d'un <Text>. Error clàssic del primer dia: <View>Hola</View> falla. Tot el text va dins de Text.
  • Esperar que els estils s'heretin. No hi ha cascada. Cada component porta el seu style; l'única herència és la de Text dins de Text.
  • Oblidar flexDirection: 'row'. Per defecte s'apila en columna. Si alguna cosa apareix una a sota de l'altra quan la volies en línia, és per això.
  • Utilitzar ScrollView amb .map() per a llistes de dades. Renderitza tot i bloqueja l'aplicació. FlatList des del primer moment.
  • Escriure fontWeight: 600. Ha de ser la cadena '600'.
  • Utilitzar px, rem o vh. Els números van sense unitat. vh i rem no existeixen; la mida de pantalla es consulta amb useWindowDimensions().
  • Copiar useMagatzemLocal sense adaptar-lo. AsyncStorage és asíncron: cal gestionar l'estat «encara no carregat».
  • Demanar tots els permisos en arrencar. Molesta l'usuari i és motiu de rebuig a les botigues. Es demanen quan es necessiten, amb context.
  • Suposar que l'aplicació funciona sense permisos. Dissenya sempre el camí alternatiu quan es deneguen.
  • Provar només en un simulador d'iOS. Les mides, els gestos, el botó enrere i el rendiment són diferents a Android i en dispositius reals de gamma mitjana.
  • Consell: comença per la lògica compartida. Copia tipus/, les utilitats pures, els slices i les consultes abans d'escriure una sola pantalla. Veuràs immediatament quant ja està fet.
  • Consell: pensa en gestos, no en clics. Lliscar, mantenir polsat, estirar per recarregar i el botó enrere són part del llenguatge mòbil. Portar la web literalment produeix una aplicació que se sent estrangera.

Exercicis

Exercici 1. Aquest component, portat apressadament de la web, té cinc errors propis de React Native. Troba'ls, explica cadascun i reescriu-lo.

import { View, StyleSheet } from 'react-native';
import EtiquetaEstat from './EtiquetaEstat';

function TargetaEstacio({ estacio, alPulsar }) {
  return (
    <View style={estils.targeta} onClick={() => alPulsar(estacio.id)}>
      <View style={estils.capcalera}>
        {estacio.nom}
        <EtiquetaEstat estat="disponible" />
      </View>
      <View style={estils.detall}>{estacio.barri} · {estacio.places} places</View>
    </View>
  );
}

const estils = StyleSheet.create({
  targeta: { backgroundColor: '#fff', padding: '16px', borderRadius: 12 },
  capcalera: { display: 'flex', justifyContent: 'space-between' },
  detall: { fontSize: 14, color: '#6b7785' },
});

Exercici 2. Escriu PantallaEstacions per a CicloUrbanoMovil: obté les estacions amb la consulta de TanStack Query reutilitzada del projecte web, les mostra en una FlatList amb «estirar per recarregar», tracta els estats de càrrega, error i llista buida, i navega al detall en polsar. Indica a més quins fitxers del projecte web has copiat sense modificar.

Exercici 3. L'equip de CicloUrbano vol que, en obrir l'aplicació, es mostri en primer lloc l'estació més propera. Dissenya la solució completa: quin permís cal i quan demanar-lo, què passa si l'usuari el denega, on viu aquesta lògica per poder-la provar, i quina part es pot reutilitzar a la web. Escriu el hook useEstacioMesPropera i explica les seves decisions.

Solucions

Solució 1. Els cinc errors:

  1. onClick no existeix. A React Native és onPress, i una View no és polsable: cal utilitzar Pressable o TouchableOpacity.
  2. Text solt dins de View. {estacio.nom} i la línia del barri han d'anar dins de <Text>.
  3. padding: '16px'. Els valors numèrics van sense unitat ni cometes: padding: 16.
  4. display: 'flex' sobra, i falta flexDirection: 'row': per defecte s'apila en columna, així que la capçalera no quedaria en línia.
  5. Falten els tipus (10-04) i l'accessibilityRole/accessibilityLabel per al lector de pantalla.
import { View, Text, Pressable, StyleSheet } from 'react-native';
import EtiquetaEstat from './EtiquetaEstat';
import type { Estacio } from '../tipus/domini';

type Props = {
  estacio: Estacio;
  alPulsar: (estacioId: string) => void;
};

function TargetaEstacio({ estacio, alPulsar }: Props) {
  return (
    <Pressable
      style={({ pressed }) => [estils.targeta, pressed && estils.polsada]}
      onPress={() => alPulsar(estacio.id)}
      accessibilityRole="button"
      accessibilityLabel={`Estació ${estacio.nom}, barri ${estacio.barri}, ${estacio.places} places`}
    >
      <View style={estils.capcalera}>
        <Text style={estils.nom}>{estacio.nom}</Text>
        <EtiquetaEstat estat="disponible" />
      </View>
      <Text style={estils.detall}>
        {estacio.barri} · {estacio.places} places
      </Text>
    </Pressable>
  );
}

const estils = StyleSheet.create({
  targeta: {
    backgroundColor: '#ffffff',
    padding: 16,
    borderRadius: 12,
    borderWidth: 1,
    borderColor: '#d9e2ec',
  },
  polsada: { opacity: 0.7 },
  capcalera: {
    flexDirection: 'row',
    justifyContent: 'space-between',
    alignItems: 'center',
    marginBottom: 4,
  },
  nom: { fontSize: 18, fontWeight: '600', color: '#1f2933' },
  detall: { fontSize: 14, color: '#6b7785' },
});

export default TargetaEstacio;

Solució 2.

// app/(pestanas)/estaciones.tsx
import { View, Text, FlatList, ActivityIndicator, RefreshControl, StyleSheet } from 'react-native';
import { useRouter } from 'expo-router';
import { useEstacions } from '../../consultes/estacions';   // copiat del projecte web
import TargetaEstacio from '../../components/TargetaEstacio';
import { colors } from '../../tema/colors';

export default function PantallaEstacions() {
  const router = useRouter();
  const { data: estacions, isPending, isError, error, refetch, isRefetching } =
    useEstacions();

  if (isPending) {
    return <ActivityIndicator style={estils.centre} size="large" color={colors.marca} />;
  }

  if (isError) {
    return (
      <View style={estils.centre}>
        <Text style={estils.error}>No s'han pogut carregar les estacions.</Text>
        <Text style={estils.detall}>{error.message}</Text>
      </View>
    );
  }

  return (
    <FlatList
      data={estacions}
      keyExtractor={(estacio) => estacio.id}
      renderItem={({ item }) => (
        <TargetaEstacio
          estacio={item}
          alPulsar={(id) => router.push(`/estaciones/${id}`)}
        />
      )}
      contentContainerStyle={estils.contingut}
      ItemSeparatorComponent={() => <View style={{ height: 12 }} />}
      ListEmptyComponent={
        <View style={estils.centre}>
          <Text>No hi ha estacions registrades.</Text>
        </View>
      }
      refreshControl={
        <RefreshControl refreshing={isRefetching} onRefresh={refetch} tintColor={colors.marca} />
      }
    />
  );
}

const estils = StyleSheet.create({
  contingut: { padding: 16 },
  centre: { flex: 1, alignItems: 'center', justifyContent: 'center', padding: 32 },
  error: { fontSize: 16, fontWeight: '600', color: colors.manteniment, marginBottom: 8 },
  detall: { fontSize: 14, color: '#6b7785' },
});

Fitxers copiats sense modificar del projecte web:

  • tipus/domini.ts — les entitats del domini.
  • tipus/esquemes.ts — els esquemes de Zod, inclosa la validació al límit.
  • consultes/estacions.ts — la consulta de TanStack Query; només s'ajusta la URL base, perquè localhost des d'un mòbil real apunta al mateix mòbil (cal fer servir la IP de la màquina de desenvolupament o una variable d'entorn).

L'única cosa que s'ha escrit de nou és la capa de presentació: FlatList en lloc de <ul>, ActivityIndicator en lloc de IndicadorDeCarrega, i RefreshControl, que no té equivalent a la web.

Solució 3.

// hooks/useEstacioMesPropera.ts
import { useState, useEffect } from 'react';
import * as Location from 'expo-location';
import { ordenarPerProximitat } from '../utilitats/geografia';   // pura i compartible
import type { Estacio } from '../tipus/domini';

type Resultat = {
  estacions: Estacio[];
  mesPropera: Estacio | null;
  usantUbicacio: boolean;
  carregant: boolean;
};

export function useEstacioMesPropera(estacions: Estacio[]): Resultat {
  const [ordenades, setOrdenades] = useState<Estacio[]>(estacions);
  const [usantUbicacio, setUsantUbicacio] = useState(false);
  const [carregant, setCarregant] = useState(true);

  useEffect(() => {
    let cancellat = false;

    async function calcular() {
      try {
        const { status } = await Location.requestForegroundPermissionsAsync();

        if (status !== 'granted') {
          // Camí alternatiu: ordre alfabètic. L'app SEGUEIX funcionant.
          if (!cancellat) {
            setOrdenades([...estacions].sort((a, b) => a.nom.localeCompare(b.nom)));
            setUsantUbicacio(false);
          }
          return;
        }

        const { coords } = await Location.getCurrentPositionAsync({
          accuracy: Location.Accuracy.Balanced,   // suficient i estalvia bateria
        });

        if (!cancellat) {
          setOrdenades(ordenarPerProximitat(estacions, coords.latitude, coords.longitude));
          setUsantUbicacio(true);
        }
      } finally {
        if (!cancellat) setCarregant(false);
      }
    }

    calcular();
    return () => { cancellat = true; };   // neteja del mòdul 5
  }, [estacions]);

  return {
    estacions: ordenades,
    mesPropera: ordenades[0] ?? null,
    usantUbicacio,
    carregant,
  };
}

Les decisions i la seva justificació:

  • Quan demanar el permís: en entrar a la pantalla d'estacions, no en arrencar l'aplicació. L'usuari entén per a què se li demana perquè està mirant una llista d'estacions. En producció convé a més una pantalla prèvia que ho expliqui abans de llançar el diàleg del sistema, perquè a iOS només es pot preguntar una vegada.
  • Si el denega: la llista s'ordena alfabèticament i usantUbicacio queda en false, perquè la interfície pugui mostrar un avís discret amb un enllaç als ajustos. La funcionalitat principal no depèn del permís.
  • On viu la lògica per poder-la provar: el càlcul és a utilitats/geografia.ts, una funció pura ordenarPerProximitat(estacions, lat, lon). Es prova amb Vitest sense simuladors ni permisos, exactament com validarReserva a 09-02. El hook només orquestra permís, obtenció i estat.
  • Què es reutilitza a la web: ordenarPerProximitat tal qual, i l'estructura del hook gairebé sencera; només se substitueix expo-location per navigator.geolocation. Val la pena extreure un obtenirPosicio() per plataforma i deixar la resta compartida.
  • La neteja amb cancellat evita actualitzar l'estat si l'usuari surt de la pantalla mentre es resol la ubicació: el patró de 05-02 aplicat igual.

Conclusió

Amb aquesta lliçó es tanca el Mòdul 10 i, amb ell, tot el recorregut tècnic del curs abans del projecte.

L'essencial de React Native és que és React amb un altre objectiu de renderitzat: el mateix reconciliador, els mateixos hooks i el mateix model declaratiu del mòdul 1, però produint vistes natives de veritat en lloc de nodes del DOM. Això el separa de la web mòbil, de la PWA i de l'aplicació híbrida, que simulen els components del sistema dins d'un WebView i sempre se senten com una web. De l'arquitectura nova n'hi ha prou amb retenir tres noms —JSI, Fabric i TurboModules— i la seva conseqüència: menys coll d'ampolla, arrencada més ràpida, i els principis de rendiment del mòdul 8 més vigents que mai, perquè un mòbil té menys marge.

La frontera és clara: tot el que és React es comparteix; tot el que és web no. Se'n van el DOM, les etiquetes HTML, el CSS amb la seva cascada i els seus selectors, localStorage i React Router. Es queden els components, les props, la composició, tots els hooks, el context, memo, Suspense, els límits d'error, Redux Toolkit, TanStack Query i els tipus de 10-04.

En eines queda muntat CicloUrbanoMovil amb Expo (npx create-expo-app, Expo Go, EAS Build i EAS Update), amb Expo Router navegant per fitxers igual que l'App Router de 10-01 —_layout.tsx, [bicicletaId].tsx, useLocalSearchParams, piles i pestanyes—. La interfície s'escriu amb View, Text, Image, TextInput, Pressable, ScrollView, FlatList i SafeAreaView, amb dues regles que cal interioritzar el primer dia: tot el text va dins de Text i no hi ha cascada d'estils. Els estils són objectes de StyleSheet.create, en camelCase, amb números sense unitat, Flexbox per defecte i flexDirection: 'column', sense selectors, sense :hover i sense media queries.

Del port de CicloUrbano, el que importa és el poc que canvia: EtiquetaEstat i TargetaBicicleta conserven la mateixa estructura de props, els mateixos tipus i la mateixa lògica; només es reescriu la presentació i les convencions de la plataforma (onPress, accessibilityLabel). I LlistaBicicletes amb FlatListdata, renderItem, keyExtractor— introdueix la lliçó de fons: FlatList no és un map, sinó una llista virtualitzada que manté en memòria una quinzena d'elements en lloc de cinc-cents. La virtualització que a 08-01 era una tècnica avançada que calia afegir, aquí és el camí per defecte.

I el balanç del que fa arrencar tota la resta: es copien sense tocar tipus/domini.ts, els esquemes de Zod, validarReserva, disponibilitat, els slices de Redux, les consultes de TanStack Query i els hooks sense DOM; s'adapten useMagatzemLocal (a AsyncStorage, que és asíncron), useAmpladaFinestra i useEstatConnexio; i es reescriu només la capa de presentació i la navegació. Per sobre d'aquest nucli, el mòbil afegeix el que la web no pot donar —ubicació, càmera i notificacions— amb la regla que les governa totes: demana el permís quan el necessitis, explica per a què, i fes que l'aplicació funcioni si et diuen que no. Tanquen el quadre les diferències de plataforma amb Platform.select, l'accessibilitat mòbil amb accessibilityLabel i accessibilityRole, les proves amb Jest i @testing-library/react-native sota el mateix principi de 09-01, i la publicació amb la seva regla d'or: JavaScript per aire amb eas update, codi natiu per la botiga.

Amb això es tanca el Mòdul 10. Vas començar el mòdul amb una aplicació que es descarregava al navegador i s'executava allà, i l'acabes sabent generar HTML al servidor i a la construcció, decidir quina estratègia mereix cada pantalla, entendre on s'executa cada component i què travessa la frontera entre servidor i client, convertir contractes implícits en tipus verificats, i portar tot aquest coneixement a un mòbil. No has après cinc tecnologies soltes: has après a triar.

I ara toca demostrar-ho. El Mòdul 11: Projecte construeix l'aplicació completa de CicloUrbano de principi a fi, amb Vite i React Router —la decisió correcta per a una eina de gestió privada, interactiva i després d'identificació, segons el criteri que tu mateix has aplicat a 10-02—. Allà s'ajunten els deu mòduls: la configuració i la planificació, la interfície amb els seus components i la seva accessibilitat, l'estat amb context, Redux Toolkit i TanStack Query, les proves en els seus quatre nivells i el desplegament a producció. La pròxima lliçó és Configuració i Planificació del Projecte.

Curs de React

Mòdul 1: Introducció a React

Mòdul 2: Components de React

Mòdul 3: Treballar amb Esdeveniments

Mòdul 4: Conceptes Avançats de Components

Mòdul 5: Hooks de React

Mòdul 6: Enrutament a React

Mòdul 7: Gestió de l'Estat

Mòdul 8: Optimització del Rendiment

Mòdul 9: Proves a React

Mòdul 10: Temes Avançats

Mòdul 11: Projecte: Construir una Aplicació Completa

© Copyright 2026. Tots els drets reservats