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
- Què és React Native i en què es diferencia de les alternatives
- Què es comparteix amb React i què no
- L'arquitectura en una idea
- Expo: crear
CicloUrbanoMovil - Components bàsics i la seva equivalència amb la web
- Estils:
StyleSheeti les diferències amb CSS - Portar
EtiquetaEstatiTargetaBicicleta LlistaBicicletesambFlatList- Què es reutilitza tal qual del projecte web
- Navegació amb Expo Router
- Capacitats del dispositiu
- Diferències de plataforma i accessibilitat mòbil
- Depuració i proves
- Publicació a les botigues
- Tancament del mòdul i pont al projecte final
- 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 | Sí | Sí | Sí |
| A les botigues | No | Limitat | Sí | Sí | Sí |
| 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í | Sí | Sí | 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.
- 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.
- 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.
- Expo: crear
CicloUrbanoMovil
CicloUrbanoMovilEs 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.
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/
- 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.
- Estils:
StyleSheet i les diferències amb CSS
StyleSheet i les diferències amb CSSNo 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 sí |
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:
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.
- Portar
EtiquetaEstat i TargetaBicicleta
EtiquetaEstat i TargetaBicicletaAquí 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.
LlistaBicicletes amb FlatList
LlistaBicicletes amb FlatListAquí 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ò:
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 | Sí |
| 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.
- 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 | localStorage → AsyncStorage (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.
- 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.
- 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.jsonamb textos comprensibles: Apple i Google rebutgen aplicacions amb justificacions genèriques, i la ubicació en segon pla és objecte de revisió especial.
- 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—.
- 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
mal 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'executanpx 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.
- 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 PlayEl 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ó:
Els usuaris el reben en el següent arrencament. El que sí 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.
- 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-webamb 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 deText. - Esperar que els estils s'heretin. No hi ha cascada. Cada component porta el seu
style; l'única herència és la deTextdins deText. - 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
ScrollViewamb.map()per a llistes de dades. Renderitza tot i bloqueja l'aplicació.FlatListdes del primer moment. - Escriure
fontWeight: 600. Ha de ser la cadena'600'. - Utilitzar
px,removh. Els números van sense unitat.vhiremno existeixen; la mida de pantalla es consulta ambuseWindowDimensions(). - Copiar
useMagatzemLocalsense 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:
onClickno existeix. A React Native ésonPress, i unaViewno és polsable: cal utilitzarPressableoTouchableOpacity.- Text solt dins de
View.{estacio.nom}i la línia del barri han d'anar dins de<Text>. padding: '16px'. Els valors numèrics van sense unitat ni cometes:padding: 16.display: 'flex'sobra, i faltaflexDirection: 'row': per defecte s'apila en columna, així que la capçalera no quedaria en línia.- Falten els tipus (10-04) i l'
accessibilityRole/accessibilityLabelper 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èlocalhostdes 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
usantUbicacioqueda enfalse, 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ó puraordenarPerProximitat(estacions, lat, lon). Es prova amb Vitest sense simuladors ni permisos, exactament comvalidarReservaa 09-02. El hook només orquestra permís, obtenció i estat. - Què es reutilitza a la web:
ordenarPerProximitattal qual, i l'estructura del hook gairebé sencera; només se substitueixexpo-locationpernavigator.geolocation. Val la pena extreure unobtenirPosicio()per plataforma i deixar la resta compartida. - La neteja amb
cancellatevita 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 FlatList —data, 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
- Què és React?
- Configuració de l'Entorn de Desenvolupament
- Hola Món amb React
- JSX: Extensió de Sintaxi de JavaScript
- Com Renderitza React: Virtual DOM i Reconciliació
Mòdul 2: Components de React
- Entendre els Components
- Components Funcionals vs de Classe
- Props: Passar Dades als Components
- State: Gestió de l'Estat del Component
- Estils en els Components: CSS, Mòduls i Utilitats
Mòdul 3: Treballar amb Esdeveniments
- Gestió d'Esdeveniments a React
- Renderitzat Condicional
- Llistes i Claus
- Formularis i Components Controlats
- Validació de Formularis i Components No Controlats
- Accessibilitat en Components Interactius
Mòdul 4: Conceptes Avançats de Components
- Elevar l'Estat
- Composició vs Herència
- Mètodes del Cicle de Vida de React
- Hooks: Introducció i Ús Bàsic
- Límits d'Error: Capturar Fallades a la Interfície
Mòdul 5: Hooks de React
- Hook useState
- Hook useEffect
- Hook useRef i Accés al DOM
- Hook useContext
- Hook useReducer
- Hooks Personalitzats
Mòdul 6: Enrutament a React
- Introducció a React Router
- Configuració de React Router
- Rutes Imbricades
- Navegació Programàtica
- Rutes Protegides i Control d'Accés
Mòdul 7: Gestió de l'Estat
- Introducció a la Gestió de l'Estat
- API de Context
- Redux: Introducció i Configuració
- Redux: Accions i Reductors
- Redux: Connectar-lo a React
- Estat del Servidor: Peticions, Memòria Cau i Sincronització
Mòdul 8: Optimització del Rendiment
- Tècniques d'Optimització del Rendiment a React
- Memoïtzació amb React.memo
- Hooks useMemo i useCallback
- Divisió de Codi i Càrrega Mandrosa
- Mesurar el Rendiment amb React DevTools Profiler
Mòdul 9: Proves a React
- Introducció a les Proves
- Proves Unitàries amb Jest
- Proves de Components amb React Testing Library
- Proves de Codi Asíncron i Simulació d'APIs
- Proves d'Extrem a Extrem amb Cypress
Mòdul 10: Temes Avançats
- Renderitzat al Servidor (SSR) amb Next.js
- Generació de Llocs Estàtics (SSG) amb Next.js
- Suspense i React Server Components
- TypeScript amb React
- React Native: Creació d'Aplicacions Mòbils
