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

  1. What React Native is and how it differs from the alternatives
  2. What's shared with React and what isn't
  3. The architecture in one idea
  4. Expo: creating CicloUrbanoMovil
  5. Basic components and their web equivalents
  6. Styles: StyleSheet and how it differs from CSS
  7. Porting StatusBadge and BikeCard
  8. BikeList with FlatList
  9. What gets reused as-is from the web project
  10. Navigation with Expo Router
  11. Device capabilities
  12. Platform differences and mobile accessibility
  13. Debugging and testing
  14. Publishing to the stores
  15. Wrapping up the module and bridging to the final project

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

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

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

  1. Expo: creating CicloUrbanoMovil

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

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

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/

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

  1. Styles: StyleSheet and how it differs from CSS

There'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:

<View style={[styles.card, isHighlighted && styles.cardHighlighted]} />

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.

  1. Porting StatusBadge and BikeCard

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

  1. BikeList with FlatList

Here'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:

<ul>
  {bikes.map((bike) => <BikeCard key={bike.id} bike={bike} />)}
</ul>

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.

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

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

  1. 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.json with clear text: Apple and Google reject apps with generic justifications, and background location gets special review scrutiny.

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

  1. 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 m in 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 running npx 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.

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

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

eas update --branch production --message "Fix discounted price calculation"

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.

  1. 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-web with 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 inside Text.
  • Expecting styles to be inherited. There's no cascade. Every component carries its own style; the only inheritance is Text nested inside Text.
  • Forgetting flexDirection: 'row'. It stacks in a column by default. If something shows up stacked when you wanted it inline, this is why.
  • Using ScrollView with .map() for data lists. It renders everything and freezes the app. Use FlatList from the start.
  • Writing fontWeight: 600. It has to be the string '600'.
  • Using px, rem or vh. Numbers are unitless. vh and rem don't exist; screen size is queried with useWindowDimensions().
  • Copying useLocalStorage without adapting it. AsyncStorage is 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:

  1. onClick doesn't exist. In React Native it's onPress, and a View isn't tappable: you need Pressable or TouchableOpacity.
  2. Loose text inside a View. {station.name} and the district line need to go inside <Text>.
  3. padding: '16px'. Numeric values are unitless and unquoted: padding: 16.
  4. display: 'flex' is redundant, and flexDirection: 'row' is missing: it stacks in a column by default, so the header wouldn't line up horizontally.
  5. The types are missing (10-04), along with accessibilityRole/accessibilityLabel for 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, because localhost from 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 usingLocation stays false, 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 function sortByProximity(stations, lat, lon). It's tested with Vitest, no simulators or permissions needed, exactly like validateBooking in 09-02. The hook only orchestrates the permission, the fetch and the state.
  • What gets reused on the web: sortByProximity as-is, and almost the entire hook structure; only expo-location gets swapped for navigator.geolocation. It's worth extracting a per-platform getPosition() and keeping the rest shared.
  • The cancelled cleanup 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 FlatListdata, 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

Module 2: React Components

Module 3: Working with Events

Module 4: Advanced Component Concepts

Module 5: React Hooks

Module 6: Routing in React

Module 7: State Management

Module 8: Performance Optimization

Module 9: Testing React Applications

Module 10: Advanced Topics

Module 11: Project: Building a Complete Application

© Copyright 2026. All rights reserved