Everything you've built in this course lives inside a browser. Components produce DOM elements, styles are CSS, events are DOM events, and storage is localStorage. This last lesson of the module makes the biggest leap yet: taking what you've learned outside the browser, into a mobile app that installs from a store and draws real native views. The good news is that most of what you know transfers intact — components, props, state, hooks, context, Redux, TanStack Query, the TypeScript types from the previous lesson; what changes is the presentation layer and the platform APIs. You're going to set up CicloUrbanoMovil with Expo, port BikeCard, StatusBadge and BikeList, understand why FlatList is not a map, navigate with Expo Router, and know exactly what code from the web project you can copy without touching a line. The goal isn't to master React Native in one lesson, but to understand the model, its limits, and what gets reused, so the leap is an informed decision.
Contents
- What React Native is and how it differs from the alternatives
- What's shared with React and what isn't
- The architecture in one idea
- Expo: creating
CicloUrbanoMovil - Basic components and their web equivalents
- Styles:
StyleSheetand how it differs from CSS - Porting
StatusBadgeandBikeCard BikeListwithFlatList- What gets reused as-is from the web project
- Navigation with Expo Router
- Device capabilities
- Platform differences and mobile accessibility
- Debugging and testing
- Publishing to the stores
- Wrapping up the module and bridging to the final project
- What React Native is and how it differs from the alternatives
React Native is React with a different rendering target. The same React you know — the same reconciler, the same hooks, the same reconciliation from module 1 — but instead of producing DOM nodes it produces native views: a UIView on iOS, an android.view.View on Android.
That means a React Native <Text> isn't a <p> in disguise. It's a real UILabel, with the system's scrolling, the system's selection, the system's accessibility and the system's performance.
A comparison with the options usually on the table:
| Mobile web | PWA | Hybrid (Cordova/Ionic) | React Native | Native (Swift/Kotlin) | |
|---|---|---|---|---|---|
| What runs | HTML/CSS/JS in the browser | Same, with a service worker | HTML/CSS/JS in a WebView | JS + native views | Native code |
| Interface | DOM elements | DOM elements | DOM elements | Native components | Native components |
| Installable | No | Partially | Yes | Yes | Yes |
| In the stores | No | Limited | Yes | Yes | Yes |
| Device access | Very limited | Limited | Via plugins | Broad | Full |
| Feel when using it | Web-like | Web-like | Web-like | Near-native | Native |
| One codebase, two platforms | Yes | Yes | Yes | Yes (~85-95%) | No |
| Learning curve from React | None | Low | Low | Medium | High |
The row that decides between hybrid and React Native is the interface row. A hybrid app simulates the system's components with HTML and CSS: scrolling doesn't have the same inertia, forms don't behave the same, and users notice even if they can't say why. React Native uses the real components.
And to be honest about that shared percentage: the 85-95% is logic, not the whole interface. Navigation, spacing, gestures and several conventions differ between iOS and Android, and a well-crafted app addresses those differences instead of ignoring them.
- What's shared with React and what isn't
This table is the mental map for the lesson.
| Shared ✅ | Doesn't exist ❌ |
|---|---|
| Components and JSX | The DOM (document, window) |
| Props and composition | HTML tags (div, p, span, img) |
useState, useEffect, useRef, useReducer, useMemo, useCallback |
CSS: stylesheets, the cascade, selectors |
useContext and the context API |
localStorage, sessionStorage |
| Custom hooks (as long as they don't touch the DOM) | fetch does exist; so does XMLHttpRequest |
memo, lazy, Suspense, error boundaries |
React Router (Expo Router is used instead) |
Redux Toolkit and react-redux |
CSS Modules |
| TanStack Query | alert, prompt (there's React Native's Alert) |
| TypeScript types | Inline SVG elements (there's react-native-svg) |
| Validation logic and pure utilities | Semantic tags (nav, header, main) |
What matters in this table is where the line falls: everything that's React is shared; everything that's web isn't. The distinction that separated server from client in 10-03 separates logic from presentation here, and it's just as useful for deciding where each thing belongs.
- The architecture in one idea
How it works internally, at the level of detail you need:
flowchart TB
subgraph JS["JavaScript thread"]
A["Your React components"] --> B["React · reconciliation"]
B --> C["View tree description"]
end
C -->|"JSI · direct calls"| D
subgraph NATIVE["Native UI thread"]
D["Fabric · renderer"] --> E["UIView (iOS)<br/>android.view.View (Android)"]
E --> F["Screen"]
end
F -->|"touches and gestures"| A
The idea in one sentence: your components describe the interface and the system paints real native views. You don't manipulate views; you describe what the current state should look like and React handles the rest. It's exactly the declarative model from module 1, with a different destination.
About the new architecture (enabled by default since React Native 0.76), you only need three names and what they solve:
- JSI (JavaScript Interface): lets JavaScript call native code directly, without serializing JSON messages across an asynchronous bridge. It removes React Native's historic bottleneck.
- Fabric: the new renderer, which enables synchronous operations and lets the interface respond within the same frame.
- TurboModules: native modules load when they're used, not all at startup, which reduces launch time.
You don't need more than that to work. What matters in practice is that React Native's classic performance problems — janky lists, animations that stutter — have improved a lot, and that the principles from module 8 (avoiding unnecessary renders, memoizing, virtualizing) are still valid and matter even more here, because a phone has less headroom than a laptop.
- Expo: creating
CicloUrbanoMovil
CicloUrbanoMovilYou can use "bare" React Native with the official CLI, but that means having Xcode and Android Studio configured from minute one, and managing native configuration by hand. Expo is the recommended way to start, and the one the official React Native documentation recommends.
The last command shows a QR code in the terminal. You install Expo Go on your phone, scan the code, and the app opens on the real device, with hot reload on save. No cables, no signing anything, no Xcode.
What Expo provides:
| Piece | What it's for |
|---|---|
| Expo Go | Running the app on a real phone without building |
| Expo SDK | Libraries for camera, location, notifications, files, sensors… already integrated |
| Expo Router | File-based navigation, like the App Router from 10-01 |
| EAS Build | Building the iOS and Android binaries in the cloud, without a Mac |
| EAS Update | Publishing JavaScript changes over the air, without going through review |
| Development builds | Building your own version when you need native code that Expo Go doesn't include |
Structure of a freshly created project:
CicloUrbanoMovil/ ├── app/ ← file-based routes (Expo Router) │ ├── _layout.tsx ← root layout │ ├── index.tsx ← initial screen "/" │ └── +not-found.tsx ├── components/ ├── assets/ ← images and fonts ├── app.json ← config: name, icon, permissions ├── tsconfig.json ← TypeScript comes preconfigured └── package.json
You'll recognize the app/ pattern: it's the same folder-based routing as Next.js. And notice that Expo's templates ship with TypeScript already configured, so everything from 10-04 applies directly.
We'll adapt the structure to the course's conventions:
CicloUrbanoMovil/ ├── app/ │ ├── _layout.tsx │ ├── (pestanas)/ │ │ ├── _layout.tsx │ │ ├── index.tsx → catalogue │ │ ├── estaciones.tsx │ │ └── reservas.tsx │ └── bicicletas/[bicicletaId].tsx ├── components/ ├── hooks/ ├── utils/ ├── types/ ← copied from the web project └── queries/
- Basic components and their web equivalents
There are no HTML tags: there are components imported from react-native.
| React Native | Web equivalent | Notes |
|---|---|---|
<View> |
<div> |
Container. Cannot contain loose text |
<Text> |
<p>, <span>, <h1> |
All text goes inside a Text, no exceptions |
<Image> |
<img> |
source={{ uri }} or require() for local files |
<TextInput> |
<input>, <textarea> |
onChangeText gives you the text directly |
<Pressable> |
<button>, <a> |
The current standard for any tappable area |
<TouchableOpacity> |
<button> |
Alternative with opacity feedback |
<ScrollView> |
<div style="overflow:scroll"> |
Renders all children at once |
<FlatList> |
A list with .map() |
Virtualized: only renders what's visible |
<SectionList> |
Grouped list | With section headers |
<SafeAreaView> |
— | Avoids the notch, the status bar and the bottom indicator |
<Modal> |
<dialog> |
Native overlay |
<ActivityIndicator> |
A custom spinner | The system's loading indicator |
<Switch> |
<input type="checkbox"> |
Native toggle |
The rule most people forget when starting out, and it's worth calling out:
// ❌ ERROR: "Text strings must be rendered within a <Text> component"
<View>Electric Pro</View>
// ✅ CORRECT
<View>
<Text>Electric Pro</Text>
</View>On the web, any element can contain text. In React Native, only <Text> can. The reason is that text is measured and painted with the native typography engine, which is a different component from a container view. In exchange, <Text> can be nested and inherits styles, which is the only style inheritance that exists in React Native:
<Text style={{ fontSize: 16, color: '#1f2933' }}>
Price: <Text style={{ fontWeight: '700' }}>€4.00/h</Text>
</Text>And for tappable elements, the current recommendation is Pressable, which gives you fine-grained control over states:
<Pressable
onPress={() => console.log('pressed')}
onLongPress={() => console.log('long press')}
style={({ pressed }) => [styles.card, pressed && styles.cardPressed]}
accessibilityRole="button"
accessibilityLabel="View Electric Pro details"
>
<Text>View details</Text>
</Pressable>Notice style as a function that receives { pressed }: it's the substitute for CSS's :active, and it's a good example of how visual states get resolved here.
- Styles:
StyleSheet and how it differs from CSS
StyleSheet and how it differs from CSSThere's no CSS. Styles are JavaScript objects, grouped with StyleSheet.create:
import { StyleSheet } from 'react-native';
const styles = StyleSheet.create({
card: {
backgroundColor: '#ffffff',
borderRadius: 12,
padding: 16,
marginBottom: 12,
borderWidth: 1,
borderColor: '#d9e2ec',
},
model: {
fontSize: 18,
fontWeight: '600',
color: '#1f2933',
marginBottom: 4,
},
price: {
fontSize: 16,
color: '#12805c',
},
});Differences from CSS, and there are many worth keeping in mind:
| CSS (web) | React Native |
|---|---|
background-color: #fff; |
backgroundColor: '#ffffff' (camelCase) |
padding: 16px; |
padding: 16 (unitless numbers, in density-independent points) |
font-weight: 600; |
fontWeight: '600' (a string) |
display: flex; |
Implicit: everything is Flexbox |
flex-direction: row; by default |
flexDirection: 'column' by default |
| Cascade and inheritance | Doesn't exist (except for nested text inside Text) |
Selectors (.class, :hover) |
Don't exist: style is passed as a prop |
| Media queries | useWindowDimensions() or Platform |
:hover, :focus, :active |
style function with { pressed }, or your own state |
box-shadow |
shadowColor/shadowOffset/shadowOpacity (iOS) + elevation (Android) |
Units %, vh, rem |
% in some cases; no vh or rem |
position: fixed |
Doesn't exist; absolute and relative do |
gap |
Yes, supported |
The two differences that confuse people the most at first:
flexDirection: 'column' by default. In CSS, a flex container stacks in a row; here it stacks in a column, which is the norm for a vertical screen. If something shows up stacked when you expected it inline, this is why.
No cascade. A style set on the parent View is not inherited by the children. Each component carries its own. It can feel like a step backward, but it wipes out the most common class of CSS bugs in one stroke: the style that arrives from some ancestor nobody remembers. It's the same philosophy as the CSS Modules from module 2, taken to the extreme.
Styles are combined by passing an array, and the last one wins:
And CicloUrbano's theme variables move into a constants module, since there's no :root:
// theme/colors.ts
export const colors = {
brand: '#12805c',
rented: '#b45309',
maintenance: '#9b1c1c',
bg: '#f5f7fa',
surface: '#ffffff',
text: '#1f2933',
border: '#d9e2ec',
} as const;The as const from 10-04 makes each value a literal, which gives exact autocomplete when you use it.
- Porting
StatusBadge and BikeCard
StatusBadge and BikeCardThis is where you see what actually changes. The props structure and the logic stay identical; only the presentation layer changes.
// components/StatusBadge.tsx
import { View, Text, StyleSheet } from 'react-native';
import type { BikeStatus } from '../types/domain';
import { colors } from '../theme/colors';
type Props = {
status: BikeStatus;
};
const LABELS: Record<BikeStatus, string> = {
disponible: 'Available',
alquilada: 'Rented',
mantenimiento: 'In maintenance',
};
const BACKGROUNDS: Record<BikeStatus, string> = {
disponible: colors.brand,
alquilada: colors.rented,
mantenimiento: colors.maintenance,
};
function StatusBadge({ status }: Props) {
return (
<View style={[styles.badge, { backgroundColor: BACKGROUNDS[status] }]}>
<Text style={styles.text}>{LABELS[status]}</Text>
</View>
);
}
const styles = StyleSheet.create({
badge: {
alignSelf: 'flex-start',
paddingHorizontal: 10,
paddingVertical: 4,
borderRadius: 999,
},
text: {
color: '#ffffff',
fontSize: 12,
fontWeight: '600',
},
});
export default StatusBadge;Compare it with the web version from 10-04: the Props type, the LABELS object and the signature are exactly the same. The only thing that changes is <span> → <View> + <Text>, and styles.badge from CSS Modules → StyleSheet. A Record<BikeStatus, string> still forces you to cover all three statuses.
// components/BikeCard.tsx
import { View, Text, Pressable, StyleSheet } from 'react-native';
import { useRouter } from 'expo-router';
import StatusBadge from './StatusBadge';
import type { Bike } from '../types/domain';
import { colors } from '../theme/colors';
type Props = {
bike: Bike;
onSelect?: (bikeId: string) => void;
};
function BikeCard({ bike, onSelect }: Props) {
const router = useRouter();
function handlePress() {
if (onSelect) onSelect(bike.id);
else router.push(`/bicicletas/${bike.id}`);
}
return (
<Pressable
onPress={handlePress}
style={({ pressed }) => [styles.card, pressed && styles.pressed]}
accessibilityRole="button"
accessibilityLabel={`${bike.model}, ${bike.status}, ${bike.pricePerHour} euros per hour`}
>
<View style={styles.header}>
<Text style={styles.model}>{bike.model}</Text>
<StatusBadge status={bike.status} />
</View>
<Text style={styles.detail}>{bike.type}</Text>
<Text style={styles.price}>{bike.pricePerHour.toFixed(2)} €/h</Text>
</Pressable>
);
}
const styles = StyleSheet.create({
card: {
backgroundColor: colors.surface,
borderRadius: 12,
borderWidth: 1,
borderColor: colors.border,
padding: 16,
marginBottom: 12,
},
pressed: { opacity: 0.7 },
header: {
flexDirection: 'row', // column by default: you have to opt in
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: 8,
},
model: { fontSize: 18, fontWeight: '600', color: colors.text },
detail: { fontSize: 14, color: '#6b7785', marginBottom: 4 },
price: { fontSize: 16, fontWeight: '600', color: colors.brand },
});
export default BikeCard;The course's prop convention is followed to the letter: internal handler handlePress, outward-facing prop onSelect. What's platform-specific is Pressable instead of <Link>, accessibilityLabel instead of aria-label, and an explicit flexDirection: 'row' on the header.
BikeList with FlatList
BikeList with FlatListHere's the most important conceptual difference in the lesson, and it connects directly to the virtualization from 08-01.
On the web you wrote this:
With 5 bikes it works fine. With 500 on a phone, the app freezes: 500 native views get created at once, memory gets consumed, and the UI thread runs out of headroom. A <ScrollView> with a map inside has exactly the same problem, because it also renders all of its children.
FlatList is a virtualized list: it only keeps the visible items and a few around them in memory, and it reuses views as you scroll.
// components/BikeList.tsx
import { FlatList, View, Text, StyleSheet, RefreshControl } from 'react-native';
import BikeCard from './BikeCard';
import type { Bike } from '../types/domain';
type Props = {
bikes: Bike[];
refreshing?: boolean;
onRefresh?: () => void;
};
function BikeList({ bikes, refreshing = false, onRefresh }: Props) {
return (
<FlatList
data={bikes}
keyExtractor={(bike) => bike.id}
renderItem={({ item }) => <BikeCard bike={item} />}
contentContainerStyle={styles.content}
ListEmptyComponent={
<View style={styles.empty}>
<Text>No bikes match that filter.</Text>
</View>
}
ListHeaderComponent={<Text style={styles.title}>Catalogue</Text>}
ItemSeparatorComponent={() => <View style={styles.separator} />}
refreshControl={
onRefresh
? <RefreshControl refreshing={refreshing} onRefresh={onRefresh} />
: undefined
}
initialNumToRender={8}
removeClippedSubviews
/>
);
}
const styles = StyleSheet.create({
content: { padding: 16 },
title: { fontSize: 24, fontWeight: '700', marginBottom: 16 },
empty: { paddingVertical: 48, alignItems: 'center' },
separator: { height: 4 },
});
export default BikeList;The essential props:
| Prop | What it does |
|---|---|
data |
The array of items |
renderItem |
Function that receives { item, index } and returns the element |
keyExtractor |
Each item's stable key (the key from module 3) |
ListEmptyComponent |
What to show when the list is empty |
ListHeaderComponent / ListFooterComponent |
Header and footer that scroll with the list |
ItemSeparatorComponent |
Separator between items |
onEndReached |
Fires when nearing the end: infinite pagination |
refreshControl |
The native "pull to refresh" gesture |
initialNumToRender |
How many items to paint in the first frame |
Why FlatList isn't a map, in table form:
.map() in a ScrollView |
FlatList |
|
|---|---|---|
| Views created with 500 items | 500 | ~15 |
| Memory | Grows with the list | Constant |
| Time to first paint | Grows with the list | Constant |
| Reuses views when scrolling | No | Yes |
| When to use it | Short, fixed lists (< 20) | Any list backed by data |
And this connects directly to module 8: virtualization, which was presented there as an advanced technique you had to add with a library, comes built in with React Native and is the default path. The reason is context: on a phone, that headroom isn't optional.
A performance tip that also comes from there: wrap BikeCard in memo and define renderItem with useCallback. While scrolling, FlatList re-renders frequently, and avoiding unnecessary renders shows up in smoothness far more visibly than on the web.
- What gets reused as-is from the web project
This is the practical question: how much code do you copy without touching it.
| From the web project | Reused? | Detail |
|---|---|---|
types/domain.ts |
✅ As-is | Depends on nothing |
utils/validateBooking.ts |
✅ As-is | Pure function |
utils/availability.ts |
✅ As-is | Pure function |
utils/classNames.js |
❌ | Joins CSS classes; doesn't apply here |
| Zod schemas | ✅ As-is | Boundary validation, same as in 10-04 |
store/ and the Redux slices |
✅ As-is | Redux Toolkit knows nothing about the DOM |
TanStack Query queries/ |
✅ Nearly as-is | fetch exists; only the base URL changes |
hooks/useToggle |
✅ As-is | Just useState |
hooks/useDebounce, useThrottle |
✅ As-is | Just timers |
hooks/usePrevious |
✅ As-is | Just useRef |
hooks/useFilteredCatalogue |
✅ As-is | Pure logic |
hooks/useLocalStorage |
⚠️ Adapt | localStorage → AsyncStorage (and it becomes async) |
hooks/useWindowWidth |
⚠️ Adapt | → React Native's useWindowDimensions() |
hooks/useKeyEvent |
❌ | There's no physical keyboard as a global event |
hooks/useOnlineStatus |
⚠️ Adapt | → @react-native-community/netinfo |
contexts/ (theme, notices) |
✅ Logic, yes | The presentation gets rewritten |
Components (BikeCard, etc.) |
❌ Rewrite | The presentation layer is different |
routes.jsx |
❌ | → Expo Router |
flowchart TB
subgraph SHARED["Shared core · copied unchanged"]
T["types/domain.ts"]
U["utils: validateBooking, availability"]
Z["Zod schemas"]
R["store: sessionSlice, catalogueSlice, bookingsSlice"]
Q["TanStack Query queries"]
H["DOM-free hooks: useToggle, useDebounce, usePrevious"]
end
SHARED --> WEB
SHARED --> MOBILE
subgraph WEB["ciclourbano-web / Vite SPA"]
W1["Components with div, span, CSS Modules"]
W2["React Router / App Router"]
end
subgraph MOBILE["CicloUrbanoMovil"]
M1["Components with View, Text, StyleSheet"]
M2["Expo Router"]
end
API[("Shared API")]
WEB --> API
MOBILE --> API
In a team maintaining both apps, that shared core gets extracted into a package (@ciclourbano/core) inside a monorepo. And notice the practical consequence of 10-04: types/domain.ts is the first thing that gets shared, because types have no dependencies and now both projects speak about the same verified domain.
The most common substitution, useLocalStorage:
// hooks/useLocalStorage.ts (mobile version)
import { useState, useEffect } from 'react';
import AsyncStorage from '@react-native-async-storage/async-storage';
export function useLocalStorage<T>(key: string, initialValue: T) {
const [value, setValue] = useState<T>(initialValue);
const [loaded, setLoaded] = useState(false);
// AsyncStorage is ASYNCHRONOUS: you can't read it in useState's initializer.
useEffect(() => {
AsyncStorage.getItem(key).then((saved) => {
if (saved !== null) setValue(JSON.parse(saved) as T);
setLoaded(true);
});
}, [key]);
useEffect(() => {
if (loaded) AsyncStorage.setItem(key, JSON.stringify(value));
}, [key, value, loaded]);
return [value, setValue, loaded] as const;
}The key difference: localStorage is synchronous and AsyncStorage isn't, so a third value shows up, loaded, indicating whether the preference has been read yet. It's the same dark-theme problem you dealt with in 10-01 with hydration, with a different cause.
- Navigation with Expo Router
Expo Router uses file-based routing, exactly like the App Router from 10-01. If you understood that, you already know this.
app/ ├── _layout.tsx → root layout ├── (pestanas)/ │ ├── _layout.tsx → tab navigator │ ├── index.tsx → "/" catalogue │ ├── estaciones.tsx → "/estaciones" │ └── reservas.tsx → "/reservas" ├── bicicletas/ │ └── [bicicletaId].tsx → "/bicicletas/bici-002" └── +not-found.tsx → unknown route
Equivalences with React Router, which you already know from module 6:
| React Router | Expo Router |
|---|---|
createBrowserRouter([...]) |
The app/ tree |
<Route path="/estaciones"> |
app/estaciones.tsx |
<Route path="/bicicletas/:bicicletaId"> |
app/bicicletas/[bicicletaId].tsx |
<Outlet /> |
<Stack />, <Tabs /> or <Slot /> in _layout.tsx |
<Link to="/estaciones"> |
expo-router's <Link href="/estaciones"> |
useNavigate() |
useRouter() |
useParams() |
useLocalSearchParams() |
useSearchParams() |
useLocalSearchParams() |
Wildcard route * |
+not-found.tsx |
ProtectedRoute |
Redirect in the _layout or <Redirect /> |
The tabs layout:
// app/(pestanas)/_layout.tsx
import { Tabs } from 'expo-router';
import { colors } from '../../theme/colors';
export default function TabsLayout() {
return (
<Tabs
screenOptions={{
tabBarActiveTintColor: colors.brand,
headerStyle: { backgroundColor: colors.surface },
}}
>
<Tabs.Screen name="index" options={{ title: 'Catalogue' }} />
<Tabs.Screen name="estaciones" options={{ title: 'Stations' }} />
<Tabs.Screen name="reservas" options={{ title: 'My bookings' }} />
</Tabs>
);
}And a detail screen with a dynamic parameter:
// app/bicicletas/[bicicletaId].tsx
import { View, Text, ActivityIndicator, StyleSheet } from 'react-native';
import { useLocalSearchParams, Stack } from 'expo-router';
import { useBike } from '../../queries/bikes';
import StatusBadge from '../../components/StatusBadge';
export default function BikeDetailScreen() {
const { bicicletaId } = useLocalSearchParams<{ bicicletaId: string }>();
const { data: bike, isPending, isError } = useBike(bicicletaId);
if (isPending) return <ActivityIndicator style={styles.center} size="large" />;
if (isError || !bike) return <Text style={styles.center}>Not found.</Text>;
return (
<View style={styles.container}>
{/* Configure the stack header from the screen itself */}
<Stack.Screen options={{ title: bike.model }} />
<Text style={styles.title}>{bike.model}</Text>
<StatusBadge status={bike.status} />
<Text style={styles.price}>{bike.pricePerHour.toFixed(2)} €/h</Text>
</View>
);
}
const styles = StyleSheet.create({
container: { flex: 1, padding: 16 },
center: { flex: 1, textAlign: 'center', marginTop: 48 },
title: { fontSize: 24, fontWeight: '700', marginBottom: 8 },
price: { fontSize: 18, color: '#12805c', marginTop: 8 },
});Two mobile navigation concepts with no web equivalent:
- Stack: screens stack up and you go back with the gesture or Android's physical back button. The slide-in entry animation is part of the platform convention.
- Tabs: the bottom bar with the main sections. Each tab keeps its own stack and its own state when you switch between them, something that would have required explicit work on the web.
And notice that useBike is the exact same TanStack Query query from the web project: the data layer is reused intact.
- Device capabilities
This is the main reason a company like CicloUrbano would want a mobile app and not just a website. Three examples with real product sense:
Location: finding the nearest station.
import * as Location from 'expo-location';
export async function getNearestStation(stations: Station[]) {
const { status } = await Location.requestForegroundPermissionsAsync();
if (status !== 'granted') {
throw new Error('Location permission denied');
}
const position = await Location.getCurrentPositionAsync({});
const { latitude, longitude } = position.coords;
return stations
.map((station) => ({
station,
distance: calculateDistance(latitude, longitude, station.lat, station.lon),
}))
.sort((a, b) => a.distance - b.distance)[0]?.station;
}Camera: scanning a bike's QR code to unlock it.
import { CameraView, useCameraPermissions } from 'expo-camera';
function QRScanner({ onScan }: { onScan: (code: string) => void }) {
const [permission, requestPermission] = useCameraPermissions();
if (!permission?.granted) {
return <Button title="Allow camera" onPress={requestPermission} />;
}
return (
<CameraView
style={{ flex: 1 }}
barcodeScannerSettings={{ barcodeTypes: ['qr'] }}
onBarcodeScanned={({ data }) => onScan(data)}
/>
);
}Notifications: warning that a booking ends in 10 minutes.
import * as Notifications from 'expo-notifications';
export async function scheduleBookingEndReminder(booking: Booking) {
const { status } = await Notifications.requestPermissionsAsync();
if (status !== 'granted') return;
const end = new Date(booking.startDate).getTime() + booking.hours * 3_600_000;
const reminder = new Date(end - 10 * 60_000);
await Notifications.scheduleNotificationAsync({
content: {
title: 'Your booking ends soon',
body: '10 minutes left. Return the bike to a station to avoid extra charges.',
},
trigger: { type: 'date', date: reminder },
});
}A warning about permissions and privacy. Each of these capabilities requires the user's explicit permission, and it can be denied. Three rules that aren't optional: (1) ask for the permission when you need it, not at launch, and explain why beforehand; (2) the app must work without it — if location is denied, show the list of stations sorted alphabetically; (3) declare the uses in
app.jsonwith clear text: Apple and Google reject apps with generic justifications, and background location gets special review scrutiny.
- Platform differences and mobile accessibility
iOS and Android aren't the same, and a well-crafted app acknowledges that instead of forcing a single design.
import { Platform, StyleSheet } from 'react-native';
const styles = StyleSheet.create({
card: {
backgroundColor: '#ffffff',
borderRadius: 12,
...Platform.select({
ios: {
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.1,
shadowRadius: 4,
},
android: {
elevation: 3, // Android uses its own elevation system
},
}),
},
header: {
paddingTop: Platform.OS === 'ios' ? 44 : 24,
},
});The differences that stand out the most:
| Aspect | iOS | Android |
|---|---|---|
| Shadows | shadow* |
elevation |
| Physical back button | Doesn't exist | Yes: you have to handle it |
| Default typeface | San Francisco | Roboto |
| Going back | Edge swipe gesture | Button or gesture |
| Fonts | Loaded with expo-font |
Same |
| Permissions | System dialog, once only | Can be asked again |
Accessibility. The concepts from module 3 carry over under different names, and on mobile they matter just as much or more, because VoiceOver (iOS) and TalkBack (Android) are very widely used:
| Web (module 3) | React Native |
|---|---|
aria-label |
accessibilityLabel |
role="button" |
accessibilityRole="button" |
aria-disabled |
accessibilityState={{ disabled: true }} |
aria-live="polite" |
accessibilityLiveRegion="polite" |
| Tab order | Natural tree order |
| Visible focus | Less relevant: the screen reader announces it |
Two rules specific to mobile: the minimum tappable area is about 44×44 points (use hitSlop if the visual element is smaller), and contrast must still meet the 4.5:1 ratio — CicloUrbano's colors already meet it.
- Debugging and testing
Briefly, because you already have the concepts from module 9.
Debugging:
- Fast Refresh: on by default, keeps state on save.
- Dev menu: shake the device or press
min the Expo terminal. - React DevTools: work the same, including the Profiler from 08-05.
- Expo debugger: network inspector, console and JavaScript breakpoints.
console.log: shows up in the terminal runningnpx expo start.
Testing, with the same tools from module 9 and their equivalents:
| Level | Web (module 9) | React Native |
|---|---|---|
| Unit | Vitest / Jest | Jest (configured by Expo) |
| Components | React Testing Library | @testing-library/react-native |
| Network mocking | MSW | MSW (compatible) |
| End-to-end | Cypress | Maestro or Detox |
The Testing Library API is nearly identical, and the guiding principle from 09-01 — test behaviour, not implementation — holds without change:
import { render, screen, fireEvent } from '@testing-library/react-native';
import BikeCard from '../components/BikeCard';
const BIKE = {
id: 'bici-002', model: 'Electric Pro', type: 'electrica',
status: 'alquilada', stationId: 'est-01', pricePerHour: 4.0,
} as const;
test('shows the model and notifies on press', () => {
const onSelect = jest.fn();
render(<BikeCard bike={BIKE} onSelect={onSelect} />);
expect(screen.getByText('Electric Pro')).toBeTruthy();
fireEvent.press(screen.getByRole('button'));
expect(onSelect).toHaveBeenCalledWith('bici-002');
});The visible difference: fireEvent.press instead of click, and getByText returns a React Native node instead of a DOM element. Role-based queries are still the preferred way, just like in 09-03, and for the same reason: if the element has no role, it has none for the screen reader either.
- Publishing to the stores
A summary of what it involves, because it changes how you plan releases.
npm install -g eas-cli
eas build --platform all # builds iOS and Android in the cloud
eas submit --platform all # submits to the App Store and Google PlayWhat you need to know before committing to dates:
| Aspect | App Store (Apple) | Google Play |
|---|---|---|
| Developer account | $99/year | $25 one-time |
| First-version review | Days | Hours or days |
| Update review | Hours or days | Usually hours |
| Common rejections | Poorly justified permissions, thin content | Privacy policies, permissions |
| Do you need a Mac | Yes to build locally; not with EAS Build | No |
And the piece that changes the pace of work: over-the-air updates. With eas update, a change that only touches JavaScript — a copy fix, a style tweak, a logic bug — gets published without going through review:
Users get it on their next launch. What does require a new version and its review is any change to native code: a new library with a native part, a new permission, a changed icon or name. The rule, worth having written down as a team: JavaScript over the air, native code through the store.
- Wrapping up the module and bridging to the final project
Module 10 has taken React into five territories that didn't exist at the start:
- 10-01 · SSR with Next.js: the HTML arrives already built; the four rendering strategies; hydration and its mismatches;
ciclourbano-webwith the App Router. - 10-02 · SSG and ISR: generate once and revalidate;
generateStaticParams,revalidate,revalidateTag; the hybrid architecture pulled together. - 10-03 · Suspense and RSC: the suspension mechanism, streaming, the
'use client'boundary and server actions. - 10-04 · TypeScript: static analysis as the cheapest layer; the domain in
types/domain.ts; components, hooks, events, Redux, Query and boundary validation with Zod. - 10-05 · React Native: the same React, outside the browser.
And now the notice already given in 10-02 and 10-03, now for real:
Module 11 builds the final project with Vite + React Router, the CicloUrbano management app you've been building since the first module. Not with Next.js or React Native.
It's not a contradiction: it's the criterion this module has given you, put into practice. The final project is a private, sign-in-gated work tool with heavy interactive state, exactly the cell in the 10-02 table where CSR is the right answer.
What the final project will put into practice, module by module:
| Module | What it applies in the project |
|---|---|
| 1-2 | Vite, JSX, components, props, state, CSS Modules |
| 3 | Events, lists and keys, controlled forms, validation, accessibility |
| 4 | Lifting state up, composition with children, error boundaries |
| 5 | All the hooks and CicloUrbano's own custom hooks |
| 6 | React Router with nested routes, protected routes and programmatic navigation |
| 7 | Context, Redux Toolkit and TanStack Query, each in its place |
| 8 | memo, useMemo/useCallback, lazy + Suspense, Profiler |
| 9 | Vitest, Testing Library, MSW and Cypress over the critical flows |
| 10 | The criterion: what to render where, and TypeScript if you decide to type it |
Common Mistakes and Tips
- Putting text outside a
<Text>. Classic day-one mistake:<View>Hello</View>fails. All text goes insideText. - Expecting styles to be inherited. There's no cascade. Every component carries its own
style; the only inheritance isTextnested insideText. - Forgetting
flexDirection: 'row'. It stacks in a column by default. If something shows up stacked when you wanted it inline, this is why. - Using
ScrollViewwith.map()for data lists. It renders everything and freezes the app. UseFlatListfrom the start. - Writing
fontWeight: 600. It has to be the string'600'. - Using
px,remorvh. Numbers are unitless.vhandremdon't exist; screen size is queried withuseWindowDimensions(). - Copying
useLocalStoragewithout adapting it.AsyncStorageis asynchronous: you have to manage the "not loaded yet" state. - Asking for all permissions at launch. It annoys users and is grounds for store rejection. Ask when you need them, with context.
- Assuming the app works without permissions. Always design the fallback path for when they're denied.
- Testing only on an iOS simulator. Sizes, gestures, the back button and performance are all different on Android and on real mid-range devices.
- Tip: start with the shared logic. Copy
types/, the pure utilities, the slices and the queries before writing a single screen. You'll immediately see how much is already done. - Tip: think in gestures, not clicks. Swiping, long-pressing, pulling to refresh and the back button are part of the mobile language. Porting the web literally produces an app that feels foreign.
Exercises
Exercise 1. This component, hastily ported from the web, has five React Native mistakes. Find them, explain each one, and rewrite it.
import { View, StyleSheet } from 'react-native';
import StatusBadge from './StatusBadge';
function StationCard({ station, onPress }) {
return (
<View style={styles.card} onClick={() => onPress(station.id)}>
<View style={styles.header}>
{station.name}
<StatusBadge status="disponible" />
</View>
<View style={styles.detail}>{station.district} · {station.docks} docks</View>
</View>
);
}
const styles = StyleSheet.create({
card: { backgroundColor: '#fff', padding: '16px', borderRadius: 12 },
header: { display: 'flex', justifyContent: 'space-between' },
detail: { fontSize: 14, color: '#6b7785' },
});Exercise 2. Write StationsScreen for CicloUrbanoMovil: it fetches the stations with the TanStack Query query reused from the web project, shows them in a FlatList with "pull to refresh", handles the loading, error and empty-list states, and navigates to the detail screen on press. Also state which files from the web project you copied unmodified.
Exercise 3. CicloUrbano's team wants the app to show the nearest station first when it opens. Design the complete solution: what permission is needed and when to ask for it, what happens if the user denies it, where that logic should live so it can be tested, and what part can be reused on the web. Write the useNearestStation hook and explain your decisions.
Solutions
Solution 1. The five mistakes:
onClickdoesn't exist. In React Native it'sonPress, and aViewisn't tappable: you needPressableorTouchableOpacity.- Loose text inside a
View.{station.name}and the district line need to go inside<Text>. padding: '16px'. Numeric values are unitless and unquoted:padding: 16.display: 'flex'is redundant, andflexDirection: 'row'is missing: it stacks in a column by default, so the header wouldn't line up horizontally.- The types are missing (10-04), along with
accessibilityRole/accessibilityLabelfor the screen reader.
import { View, Text, Pressable, StyleSheet } from 'react-native';
import StatusBadge from './StatusBadge';
import type { Station } from '../types/domain';
type Props = {
station: Station;
onPress: (stationId: string) => void;
};
function StationCard({ station, onPress }: Props) {
return (
<Pressable
style={({ pressed }) => [styles.card, pressed && styles.pressed]}
onPress={() => onPress(station.id)}
accessibilityRole="button"
accessibilityLabel={`Station ${station.name}, ${station.district} district, ${station.docks} docks`}
>
<View style={styles.header}>
<Text style={styles.name}>{station.name}</Text>
<StatusBadge status="disponible" />
</View>
<Text style={styles.detail}>
{station.district} · {station.docks} docks
</Text>
</Pressable>
);
}
const styles = StyleSheet.create({
card: {
backgroundColor: '#ffffff',
padding: 16,
borderRadius: 12,
borderWidth: 1,
borderColor: '#d9e2ec',
},
pressed: { opacity: 0.7 },
header: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: 4,
},
name: { fontSize: 18, fontWeight: '600', color: '#1f2933' },
detail: { fontSize: 14, color: '#6b7785' },
});
export default StationCard;Solution 2.
// app/(pestanas)/estaciones.tsx
import { View, Text, FlatList, ActivityIndicator, RefreshControl, StyleSheet } from 'react-native';
import { useRouter } from 'expo-router';
import { useStations } from '../../queries/stations'; // copied from the web project
import StationCard from '../../components/StationCard';
import { colors } from '../../theme/colors';
export default function StationsScreen() {
const router = useRouter();
const { data: stations, isPending, isError, error, refetch, isRefetching } =
useStations();
if (isPending) {
return <ActivityIndicator style={styles.center} size="large" color={colors.brand} />;
}
if (isError) {
return (
<View style={styles.center}>
<Text style={styles.error}>Couldn't load the stations.</Text>
<Text style={styles.detail}>{error.message}</Text>
</View>
);
}
return (
<FlatList
data={stations}
keyExtractor={(station) => station.id}
renderItem={({ item }) => (
<StationCard
station={item}
onPress={(id) => router.push(`/estaciones/${id}`)}
/>
)}
contentContainerStyle={styles.content}
ItemSeparatorComponent={() => <View style={{ height: 12 }} />}
ListEmptyComponent={
<View style={styles.center}>
<Text>No stations registered.</Text>
</View>
}
refreshControl={
<RefreshControl refreshing={isRefetching} onRefresh={refetch} tintColor={colors.brand} />
}
/>
);
}
const styles = StyleSheet.create({
content: { padding: 16 },
center: { flex: 1, alignItems: 'center', justifyContent: 'center', padding: 32 },
error: { fontSize: 16, fontWeight: '600', color: colors.maintenance, marginBottom: 8 },
detail: { fontSize: 14, color: '#6b7785' },
});Files copied unmodified from the web project:
types/domain.ts— the domain entities.types/schemas.ts— the Zod schemas, including boundary validation.queries/stations.ts— the TanStack Query query; only the base URL gets adjusted, becauselocalhostfrom a real phone points at the phone itself (you need the development machine's IP or an environment variable).
The only thing written from scratch is the presentation layer: FlatList instead of <ul>, ActivityIndicator instead of LoadingIndicator, and RefreshControl, which has no web equivalent.
Solution 3.
// hooks/useNearestStation.ts
import { useState, useEffect } from 'react';
import * as Location from 'expo-location';
import { sortByProximity } from '../utils/geography'; // pure and shareable
import type { Station } from '../types/domain';
type Result = {
stations: Station[];
nearest: Station | null;
usingLocation: boolean;
loading: boolean;
};
export function useNearestStation(stations: Station[]): Result {
const [sorted, setSorted] = useState<Station[]>(stations);
const [usingLocation, setUsingLocation] = useState(false);
const [loading, setLoading] = useState(true);
useEffect(() => {
let cancelled = false;
async function calculate() {
try {
const { status } = await Location.requestForegroundPermissionsAsync();
if (status !== 'granted') {
// Fallback: alphabetical order. The app KEEPS working.
if (!cancelled) {
setSorted([...stations].sort((a, b) => a.name.localeCompare(b.name)));
setUsingLocation(false);
}
return;
}
const { coords } = await Location.getCurrentPositionAsync({
accuracy: Location.Accuracy.Balanced, // good enough, and saves battery
});
if (!cancelled) {
setSorted(sortByProximity(stations, coords.latitude, coords.longitude));
setUsingLocation(true);
}
} finally {
if (!cancelled) setLoading(false);
}
}
calculate();
return () => { cancelled = true; }; // cleanup from module 5
}, [stations]);
return {
stations: sorted,
nearest: sorted[0] ?? null,
usingLocation,
loading,
};
}The decisions and their reasoning:
- When to ask for the permission: on entering the stations screen, not at app launch. The user understands why they're being asked because they're looking at a list of stations. In production it's also worth having a screen beforehand that explains it before triggering the system dialog, because on iOS you can only ask once.
- If it's denied: the list is sorted alphabetically and
usingLocationstaysfalse, so the UI can show a discreet notice with a link to settings. The core functionality doesn't depend on the permission. - Where the logic lives so it can be tested: the calculation is in
utils/geography.ts, a pure functionsortByProximity(stations, lat, lon). It's tested with Vitest, no simulators or permissions needed, exactly likevalidateBookingin 09-02. The hook only orchestrates the permission, the fetch and the state. - What gets reused on the web:
sortByProximityas-is, and almost the entire hook structure; onlyexpo-locationgets swapped fornavigator.geolocation. It's worth extracting a per-platformgetPosition()and keeping the rest shared. - The
cancelledcleanup avoids updating state if the user leaves the screen while the location is still resolving: the same pattern from 05-02, applied here.
Conclusion
This lesson closes out Module 10 and, with it, the entire technical journey of the course before the project.
The essence of React Native is that it's React with a different rendering target: the same reconciler, the same hooks and the same declarative model from module 1, but producing real native views instead of DOM nodes. That's what sets it apart from the mobile web, from PWAs and from hybrid apps, which simulate the system's components inside a WebView and always feel like a website. From the new architecture, you only need to hold on to three names — JSI, Fabric and TurboModules — and their consequence: less of a bottleneck, faster startup, and the performance principles from module 8 more relevant than ever, because a phone has less headroom.
The line is clear: everything that's React is shared; everything that's web isn't. The DOM goes, HTML tags go, CSS with its cascade and selectors goes, localStorage goes, and so does React Router. What stays: components, props, composition, all the hooks, context, memo, Suspense, error boundaries, Redux Toolkit, TanStack Query and the types from 10-04.
In terms of tooling, you've set up CicloUrbanoMovil with Expo (npx create-expo-app, Expo Go, EAS Build and EAS Update), with Expo Router navigating by files just like the App Router from 10-01 — _layout.tsx, [bicicletaId].tsx, useLocalSearchParams, stacks and tabs. The interface is written with View, Text, Image, TextInput, Pressable, ScrollView, FlatList and SafeAreaView, with two rules to internalize on day one: all text goes inside Text and there's no style cascade. Styles are StyleSheet.create objects, in camelCase, with unitless numbers, Flexbox by default and flexDirection: 'column', with no selectors, no :hover and no media queries.
From porting CicloUrbano, what matters is how little changes: StatusBadge and BikeCard keep the same props structure, the same types and the same logic; only the presentation and the platform conventions (onPress, accessibilityLabel) get rewritten. And BikeList with FlatList — data, renderItem, keyExtractor — introduces the deeper lesson: FlatList isn't a map, but a virtualized list that keeps around fifteen items in memory instead of five hundred. The virtualization that was an advanced technique you had to add in 08-01 is the default path here.
And the balance sheet everything else builds on: copied without changes are types/domain.ts, the Zod schemas, validateBooking, availability, the Redux slices, the TanStack Query queries and the DOM-free hooks; adapted are useLocalStorage (to AsyncStorage, which is async), useWindowWidth and useOnlineStatus; and only the presentation layer and navigation get rewritten. On top of that core, mobile adds what the web can't give you — location, camera and notifications — under the one rule that governs all of them: ask for the permission when you need it, explain why, and make the app work if they say no. Rounding things out are the platform differences with Platform.select, mobile accessibility with accessibilityLabel and accessibilityRole, testing with Jest and @testing-library/react-native under the same principle from 09-01, and publishing with its golden rule: JavaScript over the air with eas update, native code through the store.
With this, Module 10 comes to a close. You started the module with an app that got downloaded to the browser and ran there, and you're finishing it knowing how to generate HTML on the server and at build time, decide which strategy each screen deserves, understand where each component runs and what crosses the server/client boundary, turn implicit contracts into verified types, and carry all of that knowledge onto a phone. You haven't learned five separate technologies: you've learned to choose.
And now it's time to prove it. Module 11: Project builds the complete CicloUrbano app from start to finish, with Vite and React Router — the right call for a private, interactive, sign-in-gated management tool, by the very criterion you applied yourself in 10-02. That's where the ten modules come together: setup and planning, the interface with its components and accessibility, state with context, Redux Toolkit and TanStack Query, testing across its four levels, and deployment to production. The next lesson is Setup and Planning.
React Course
Module 1: Getting Started with React
- What Is React?
- Setting Up the Development Environment
- Hello World in React
- JSX: A JavaScript Syntax Extension
- How React Renders: Virtual DOM and Reconciliation
Module 2: React Components
- Understanding Components
- Function vs Class Components
- Props: Passing Data to Components
- State: Managing Component State
- Styling Components: CSS, Modules and Utilities
Module 3: Working with Events
- Handling Events in React
- Conditional Rendering
- Lists and Keys
- Forms and Controlled Components
- Form Validation and Uncontrolled Components
- Accessibility in Interactive Components
Module 4: Advanced Component Concepts
- Lifting State Up
- Composition vs Inheritance
- React Lifecycle Methods
- Hooks: Introduction and Basic Use
- Error Boundaries: Catching Failures in the UI
Module 5: React Hooks
- The useState Hook
- The useEffect Hook
- The useRef Hook and DOM Access
- The useContext Hook
- The useReducer Hook
- Custom Hooks
Module 6: Routing in React
- Introducing React Router
- Setting Up React Router
- Nested Routes
- Programmatic Navigation
- Protected Routes and Access Control
Module 7: State Management
- Introduction to State Management
- The Context API
- Redux: Introduction and Setup
- Redux: Actions and Reducers
- Redux: Connecting to React
- Server State: Fetching, Caching and Syncing
Module 8: Performance Optimization
- Performance Optimization Techniques in React
- Memoization with React.memo
- The useMemo and useCallback Hooks
- Code Splitting and Lazy Loading
- Measuring Performance with React DevTools Profiler
Module 9: Testing React Applications
- Introduction to Testing
- Unit Testing with Jest
- Component Testing with React Testing Library
- Testing Asynchronous Code and Mocking APIs
- End-to-End Testing with Cypress
Module 10: Advanced Topics
- Server-Side Rendering (SSR) with Next.js
- Static Site Generation (SSG) with Next.js
- Suspense and React Server Components
- TypeScript with React
- React Native: Building Mobile Apps
