1. Courses
  2. Engineering Roles
  3. Mobile Apps with React Native
  4. Stop 2

40 minIntermediate

Navigation and app structure

A rider app is a handful of screens, and the way riders move between them decides whether they reach the right job in two taps or get lost at a customer's gate. This lesson shows you how mobile navigation works, how Expo Router turns files into screens, how a link or a push notification opens one exact job, and which state survives when the phone closes your app.

By the end you can

  • Choose a stack, tabs or a modal for each screen, and predict what the Android back button and the iOS swipe will do
  • Lay out an app in Expo Router with layouts, groups and dynamic routes, passing ids instead of objects
  • Set up deep links with a custom scheme and verified https links, and open a job from a notification tap
  • Decide where each piece of screen state lives so it survives tab switches, back presses and a killed app
  • Gate screens behind sign-in without losing the link the rider tapped

The everyday picture

Think about shopping in a Mangrove store. The aisles work like tabs. You walk from the dairy aisle to the rice aisle and back, and the dairy aisle is the way you left it, with the two yogurts you were comparing still on the shelf. Picking up a jar to read its label works like a stack: you pick up one jar, then maybe a second, and you put them back in reverse order. When the cashier asks "Do you need a bag?", you answer before you can go on. That question works like a modal, a short task that sits on top of everything else.

Now the shelf labels. Every shelf has a code such as "aisle 7, shelf 3". A staff member can send you that code in a message, and you walk straight there without browsing. That is a deep link, an address for one exact spot. The stock room door says "staff only". If you are not staff, you are sent to the front desk, and a good desk writes down where you were trying to go. That is a sign-in gate.

Mobile navigation is these four ideas, plus one rule a real store never faces: the phone can close your app at any moment to free memory, and when the rider comes back, the app must rebuild the store from its notes.

Stacks, tabs and modals

Almost every mobile app is built from three patterns. A keeps screens in a pile. Opening a screen pushes it on top, and going back pops it off. In the Rider app, tapping a job pushes the job detail on top of the job list. The list stays mounted underneath, so when the rider goes back it is still scrolled to the same job. The screen that was popped is unmounted, and any state inside it is gone.

switch between top-level sections that live side by side. The Rider app has two: Jobs and Earnings. In React Navigation 7, which Expo Router is built on, a tab screen mounts the first time the rider opens it and then stays mounted. So the Jobs tab keeps its scroll position while the rider checks today's earnings. A tab can also hold its own stack, with its own history.

A is a screen for one short task that covers the current screen and has a clear way out: done or cancel. Proof of delivery fits. The rider takes a photo or enters the customer's code, then returns to the job. On iOS a modal slides up from the bottom, and the rider can drag it down to close it.

Going back works differently on the two platforms, and riders use both. Android has a system back button or back gesture. In a stack it pops the top screen. In bottom tabs, the default backBehavior is firstRoute, so back from Earnings returns to Jobs, and back again leaves the app. iOS has no system back button. The stack draws a back arrow in the header, and the rider can swipe from the left edge of the screen; in the native stack, gestureEnabled is on by default on iOS. Your job is to make sure every kind of back lands somewhere sensible, and that is mostly a matter of choosing the right structure.

To choose, ask what the rider is doing. Going deeper into one thing (the list, then a job, then the store's pickup notes) is a stack. Switching between areas the rider uses all day is tabs; keep them few, and the Rider app needs only two. A self-contained task with an end is a modal. One more rule prevents many bugs: put detail screens in a stack above the tabs, so the tab bar hides on the detail screen and back returns to the tab the rider came from. Expo's own docs recommend this layout.

Quick check

A rider is on the Earnings tab, with default bottom tabs, and presses the Android back button once. What happens?

root stack Proof modal /proof/123 Job detail /jobs/123 Sign-in signed out (tabs) Jobs / Earnings /earnings tap job deliver back or swipe back or swipe signed in: guard flips tap back
The Rider app's structure. The two tabs sit at the bottom of the root stack. Tapping a job pushes the detail above the tabs, and Deliver opens proof as a modal. Back (or the iOS swipe) pops one layer at a time, and back from Earnings goes to Jobs. Signed-out riders only see sign-in; when they sign in, the guard swaps it for the tabs.

Expo Router: the file tree is the navigation tree

Expo Router builds the navigation from files. Every file in the app folder becomes a route with a URL, and the folders decide which screens form a stack and which form tabs. Under the hood it uses React Navigation, so stacks, tabs and their options behave as described above; you write files instead of one big configuration object. In Expo SDK 58 the default template keeps routes in src/app; older projects have app at the project root, with the same rules. Here is the Rider app:

Output
src/app/
  _layout.tsx          root stack: sign-in gate, tabs, detail, modal
  sign-in.tsx          /sign-in
  help.tsx             /help         open to everyone
  (tabs)/
    _layout.tsx        the tab bar: Jobs and Earnings
    index.tsx          /             Jobs tab: today's job list
    earnings.tsx       /earnings     Earnings tab
  jobs/
    [id].tsx           /jobs/123     job detail, pushed above the tabs
  proof/
    [jobId].tsx        /proof/123    proof of delivery, shown as a modal
  +native-intent.tsx   checks every link the system hands to the app
  +not-found.tsx       any URL that matches no file
  • _layout.tsx is not a screen. It decides how the routes in its folder relate, by rendering a Stack, Tabs or a plain Slot. The root layout renders before any route, so app-wide setup goes there: the session, fonts, the notification listener.
  • A folder in parentheses is a . It groups files and can have its own layout, but its name never appears in the URL, so (tabs)/earnings.tsx is /earnings.
  • Square brackets make a . jobs/[id].tsx matches /jobs/123 and /jobs/124, and the screen reads the value with useLocalSearchParams().
  • index.tsx is the default route of its folder. (tabs)/index.tsx is /, the first screen the app opens.
  • Files starting with + are special. +not-found catches unknown URLs, and +native-intent lets you rewrite a link from the system before the router sees it.

Typed routes. With experiments.typedRoutes set in app.json (projects from the default template already have it), npx expo start generates a type for every route. A typo such as router.push('/jbos') becomes a TypeScript error, and a dynamic route must be given its params as an object: { pathname: '/jobs/[id]', params: { id } }. Expo still calls typed routes beta in SDK 58, so keep the dev server running while you add files, or the types fall behind.

Pass ids, not objects. The tempting move is to hand the whole job to the detail screen. Params end up in the URL, and URL params are strings, so an object has to be turned into text first. Worse, the copy goes stale. The rider's list was loaded at 10:02, the dispatcher changed the drop address at 10:05, and the detail screen still shows the old address from its params. React Navigation's docs give the same advice: params hold identifiers, and the data comes from a store or cache. Pass the id, and let the detail screen read the job from the shared cache the list filled. Then a deep link that carries only an id works too, because the detail screen already knows how to load a job by id. (State, effects and where data should live covers choosing the owner of each piece of data in React.)

Headers. Each stack screen gets a header with a title and a back arrow. Set fixed titles in the layout with options={{ title: 'Help' }}, and dynamic ones from inside the screen by rendering <Stack.Screen options={{ title: job.storeName }} />, so the header shows the pickup store once the job has loaded.

Quick check

The job list passes the whole job object to the detail screen as a param. What goes wrong first?

A is a URL that opens your app on one exact screen. The Rider app needs them for three ways in: a dispatcher sends a rider a job link in a chat app like WhatsApp or Telegram, a QR code at a store's pickup counter, and a push notification for a new job. Expo Router gives every screen a URL, so the routing part comes almost free. The real work is making links arrive safely.

Custom scheme. Setting "scheme": "mangrove-rider" in app.json tells the phone to hand mangrove-rider://jobs/123 to your app, and the part after the scheme maps to the route /jobs/123. Schemes are quick to set up. A new scheme needs a new , because Expo Go only answers its own exp:// links. They have two weaknesses. If the app is not installed, the link does nothing useful, and any app can claim the same scheme, so nothing proves the link belongs to you.

Verified https links. and iOS use ordinary https URLs on a domain you own, such as https://rider.mangrove.example/jobs/123. You prove ownership with a small file on that domain: /.well-known/assetlinks.json for Android lists your package name and the SHA-256 fingerprint of your signing certificate, and /.well-known/apple-app-site-association for iOS lists your team ID and bundle ID. In app.json, an Android intent filter with autoVerify: true and the iOS entry applinks:rider.mangrove.example tell each phone to check. When the check passes, the link opens the app directly. When the app is missing, the same URL opens a web page, where you can show the job or a download button. Since Android 12, an https link that fails verification opens in the browser instead, so a wrong fingerprint fails quietly.

Push taps. A notification carries a data payload, and you decide what goes in it. Put a link in it, such as data: { url: 'mangrove-rider://jobs/123' }, and handle two cases. If the app was running, in front or in the background, a listener from Notifications.addNotificationResponseReceivedListener fires when the rider taps. If the app was not running, the tap launches it from scratch (a ), and you read that tap with Notifications.getLastNotificationResponse() when the root layout mounts. Expo's docs put both in one hook in the root layout, and so does Build it. Sending notifications and asking for permission are covered in Device features.

Check every link. Treat incoming links as user input. A link can be a typo in a chat message, an old notification for a job that was given to another rider, or someone testing what your app accepts. Parse it, check the host, the path and the id, and send anything wrong to a safe screen, the job list with a short notice, instead of an error page or a crash. One function, parseDeepLink, used by both +native-intent and the notification hook, keeps the rules in one place. You write it in Practice.

Scheme link unverified https link domain verified Push tap data.url opens app Parse and check host, path, id valid bad link Signed in? yes no: keep link Job detail /jobs/123 Sign-in link saved Job list with a notice after sign-in
Three ways in, one door. Scheme links, verified https links and notification taps all go through the same parse-and-check step. A valid link opens the job if the rider is signed in; otherwise sign-in shows and the link is saved for afterwards. A bad link lands on the job list with a notice, never on a crash.

Screen state: tab switches, back presses and a killed app

Every piece of state in a screen has a lifetime, and navigation decides how long it is. Before you write a screen, sort its state into these kinds:

StateRider app exampleWhere it livesWhat it survives
Which screen, which job/jobs/123The route and its paramsLinks and notifications; a cold start only if you restore it
View stateList scrolled to job 9, a section openeduseState in the screenTab switches; lost when the screen is popped or the app is killed
Server dataToday's jobs, earningsA shared cacheMoving between screens; fetched again after a cold start
The rider's unsaved workA delivery note, the job in progressStorage on the phone, written as the rider goesEverything, including a killed app
SessionThe sign-in tokenSecure storageApp restarts, until sign-out or expiry

The killed app. On a phone with 2 to 3 GB of memory, Android closes apps in the background to make room. A rider who follows Google Maps to a customer for fifteen minutes may come back to a fresh start: the JavaScript state is gone, and the Rider app opens on its first screen as if it had never run. iOS does the same under memory pressure. You cannot prevent this, so design for it. Anything the rider would be upset to lose is written to storage as they go, and the app can rebuild "where was I?" from storage. For the Rider app, that is the job in progress. Save its id when the rider picks up the order, and on a cold start open /jobs/<id> if that job is still picked up. The storage choices and the queue for proof of delivery are in Offline first.

Signed-out stack and app stack. A signed-out rider should only see sign-in and help, and a signed-in rider should never land on sign-in. Expo Router expresses this with Stack.Protected in the root layout. Screens inside <Stack.Protected guard={isSignedIn}> exist only while the guard is true. If the rider tries to open one while it is false, Expo Router redirects to the first screen they are allowed to see. When the guard changes, at sign-in or sign-out, screens that are no longer allowed are removed from the history, so back cannot reach them. The guard only decides what the app shows. The API must still check the token on every request, because a guard on the phone protects nothing on the server.

Keep the link at the gate. A rider whose session has expired taps a "new job" notification. The guard sends them to sign-in, and Expo's own sign-in example then goes to /, so the job they wanted is lost unless you keep it. Save the link before the redirect, and open it after sign-in. Also wait until the session is known before you navigate at all. On a cold start the session check and the notification tap arrive together, and a push that runs before the guard knows who is signed in can send the rider to the wrong screen. Keep the splash screen up until the session has loaded.

Quick check

A rider types half a delivery note on the job detail screen, switches to Maps, and Android closes the Rider app. Where should the note have been kept?

Build it

Turn the job list from How React Native works into a real app structure: a Jobs tab and an Earnings tab, a job detail screen opened by id, a sign-in gate, and deep links from a scheme, an https domain and notification taps, all through one checked door. Continue in your rider-app project (Expo SDK 58). Give the sample jobs numeric ids such as '48213', the format the Rider API uses.

  1. Turn on the scheme, typed routes and https links

    Add these keys to app.json (keep the ones the template already has).

    JSON
    {
      "expo": {
        "name": "Mangrove Rider",
        "scheme": "mangrove-rider",
        "experiments": { "typedRoutes": true },
        "ios": {
          "bundleIdentifier": "example.mangrove.rider",
          "associatedDomains": ["applinks:rider.mangrove.example"]
        },
        "android": {
          "package": "example.mangrove.rider",
          "intentFilters": [
            {
              "action": "VIEW",
              "autoVerify": true,
              "data": [{ "scheme": "https", "host": "rider.mangrove.example", "pathPrefix": "/jobs" }],
              "category": ["BROWSABLE", "DEFAULT"]
            }
          ]
        }
      }
    }
  2. Move the screens into the route tree

    Create the files from the tree in the Expo Router section. Move the home screen from rn-01 into src/app/(tabs)/index.tsx and add one more ../ to its imports. For now, earnings.tsx, help.tsx and proof/[jobId].tsx can each return one Text; the proof screen fills up in rn-03 and rn-04. Keep the template's +not-found.tsx. Put the session hook from Expo's authentication guide in src/features/auth/session.tsx; it exposes session, isLoading and signIn.

  3. Write the root layout: the gate, the stack and the listeners

    TypeScript
    // src/app/_layout.tsx
    import { Stack } from 'expo-router';
    import * as SplashScreen from 'expo-splash-screen';
    import { useEffect } from 'react';
    import { SessionProvider, useSession } from '../features/auth/session';
    import { useNotificationObserver } from '../features/links/useNotificationObserver';
    
    SplashScreen.preventAutoHideAsync();                     // keep the splash until the session is known
    
    export const unstable_settings = { anchor: '(tabs)' };   // a link to /jobs/123 gets the tabs below it
    
    export default function RootLayout() {
      return (
        <SessionProvider>
          <RootStack />
        </SessionProvider>
      );
    }
    
    function RootStack() {
      const { session, isLoading } = useSession();
      useEffect(() => { if (!isLoading) SplashScreen.hide(); }, [isLoading]);
      useNotificationObserver(!isLoading, !!session);        // taps wait for the session (step 6)
      return (
        <Stack>
          <Stack.Protected guard={!!session}>
            <Stack.Screen name="(tabs)" options={{ headerShown: false }} />
            <Stack.Screen name="jobs/[id]" options={{ title: 'Job' }} />
            <Stack.Screen name="proof/[jobId]" options={{ presentation: 'modal', title: 'Proof of delivery' }} />
          </Stack.Protected>
          <Stack.Protected guard={!session}>
            <Stack.Screen name="sign-in" options={{ headerShown: false }} />
          </Stack.Protected>
          <Stack.Screen name="help" options={{ title: 'Help' }} />
        </Stack>
      );
    }

    The anchor setting matters for links: a rider who opens /jobs/123 from a chat message still gets the tabs underneath, so back goes to the job list instead of leaving the app.

  4. Add the tabs, and join the list to the detail by id

    TypeScript
    // src/app/(tabs)/_layout.tsx
    import Ionicons from '@expo/vector-icons/Ionicons';
    import { Tabs } from 'expo-router';
    
    export default function TabLayout() {
      return (
        <Tabs screenOptions={{ tabBarActiveTintColor: '#2f6f4f' }}>
          <Tabs.Screen
            name="index"
            options={{ title: 'Jobs', tabBarIcon: ({ color, size }) => <Ionicons name="list" color={color} size={size} /> }}
          />
          <Tabs.Screen
            name="earnings"
            options={{ title: 'Earnings', tabBarIcon: ({ color, size }) => <Ionicons name="wallet" color={color} size={size} /> }}
          />
        </Tabs>
      );
    }

    In the real app, sampleJobs.find becomes a hook over a shared cache of GET /rider/jobs that fetches the job when it is missing. A data library such as TanStack Query gives you that cache.

  5. Send every system link through one door

    Put your Practice solutions in src/features/links/: parseDeepLink with a small hrefFor next to it, and gate with afterSignIn. Then add a tiny store for a link waiting on sign-in, and the special +native-intent file.

    TypeScript
    // src/app/+native-intent.tsx: runs before routing for links the system opens
    import { hrefFor, parseDeepLink } from '../features/links/parseDeepLink';
    
    export function redirectSystemPath({ path }: { path: string; initial: boolean }) {
      try {
        // handle both a full URL and a bare path such as /jobs/123
        const url = path.startsWith('/') ? 'mangrove-rider:/' + path : path;
        return hrefFor(parseDeepLink(url));     // bad links become '/', the job list
      } catch {
        return '/';                             // never crash in here
      }
    }
  6. Open the right job from a notification tap, after sign-in if needed

    TypeScript
    // src/features/links/useNotificationObserver.ts
    import { router, type Href } from 'expo-router';
    import * as Notifications from 'expo-notifications';
    import { useEffect } from 'react';
    import { gate } from './gate';
    import { hrefFor, parseDeepLink } from './parseDeepLink';
    import { rememberLink } from './pending';
    
    export function useNotificationObserver(ready: boolean, signedIn: boolean) {
      useEffect(() => {
        if (!ready) return;                                    // wait until we know who is signed in
        function open(response: Notifications.NotificationResponse) {
          const url = response.notification.request.content.data?.url;
          const href = typeof url === 'string' ? hrefFor(parseDeepLink(url)) : '/';
          const { show, remember } = gate(signedIn, href);
          if (remember) rememberLink(remember);                // the sign-in screen opens it later
          else router.push(show as Href);
          Notifications.clearLastNotificationResponse();       // handle each tap once
        }
        const last = Notifications.getLastNotificationResponse();   // the tap that launched the app
        if (last) open(last);
        const sub = Notifications.addNotificationResponseReceivedListener(open);   // taps while running
        return () => sub.remove();
      }, [ready, signedIn]);
    }

    The observer uses gate to decide, and the sign-in screen uses afterSignIn to finish the job. Install the notifications module with npx expo install expo-notifications. Sending a real push comes in Device features.

  7. Make a development build and test every way in

    A new scheme and new intent filters are native settings, so Expo Go cannot use them. Build a development build on your machine (this needs Android Studio), or in the cloud with EAS:

    Terminal
    npx expo install expo-dev-client
    npx expo run:android

    With the app installed, open a scheme link, a bad link and a verified https link from your computer:

    Terminal
    npx uri-scheme open mangrove-rider://jobs/48213 --android
    npx uri-scheme open "mangrove-rider://jobs/48213?from=chat" --android
    npx uri-scheme open mangrove-rider://jobs/abc --android
    adb shell am start -a android.intent.action.VIEW -c android.intent.category.BROWSABLE -d "https://rider.mangrove.example/jobs/48213" example.mangrove.rider
    adb shell pm get-app-links example.mangrove.rider

    The first two open job 48213 with the tabs below it; press back and you land on the job list. The third lands on the job list. The https link opens the app only after rider.mangrove.example serves the two files below, and pm get-app-links shows whether Android has verified the domain (it can take a little while after install). Use the SHA-256 fingerprint from eas credentials -p android or from the Play Console's app signing page.

    JSON
    [
      {
        "relation": ["delegate_permission/common.handle_all_urls"],
        "target": {
          "namespace": "android_app",
          "package_name": "example.mangrove.rider",
          "sha256_cert_fingerprints": ["PASTE:THE:SHA256:OF:THE:PLAY:APP:SIGNING:KEY"]
        }
      }
    ]

    Both go under /.well-known/ on the domain, over https. Last, the killed-app test from the example box: open a job, press Home, force-stop the app in Android settings, and open it again. Until you save the job in progress (rn-03), it opens on the job list, which is exactly the gap to close.

Bring it to your team

Ask these in design reviews and when a new screen is added to the Rider app.

  • If I paste a link to a job that is not mine, or to a job id that does not exist, what does the app show?
  • Can every screen we link to load its own data from the id in the URL, with an empty cache?
  • What does Android back do on each screen, and does the iOS swipe go to the same place?
  • If Android closes the app on the proof screen, what does the rider lose? Have we run the force-stop test?
  • Where is the link the rider tapped kept while they sign in, and when is it forgotten?
  • Who owns assetlinks.json and the Apple association file on our domain, and do they list the Play signing key?

Red flags

  • Whole objects passed as params, or screens that only work when opened from the list.
  • router.push(data.url) straight from a notification, with no checks.
  • Five or more tabs, or tabs used for the steps of one task.
  • Deep links only ever tested in Expo Go, never from a cold start in a development build.
  • "The guard hides that screen, so the API does not need to check."

Practice

Parse the Rider app's deep links

Write parseDeepLink(url). It must accept the custom scheme mangrove-rider:// and verified links on https://rider.mangrove.example, and return one of these:

  • { screen: 'jobs', params: {} } for /jobs or an empty path (the job list)
  • { screen: 'job', params: { id: '48213' } } for /jobs/48213; ids are digits only, otherwise { error: 'bad-id' }
  • { screen: 'earnings', params: {} } for /earnings
  • { error: 'unknown-host' } for any other scheme or host, including plain http
  • { error: 'unknown-path' } for anything else

A query string and a trailing slash are not part of the path. The starter only handles the scheme form. Tip: turn a scheme link into the https form first, then let new URL() do the parsing.

JavaScript exerciseRuns in your browser with tests
function parseDeepLink(url) {
  // Handles mangrove-rider://jobs/48213 ... and nothing else yet
  const parts = url.replace('mangrove-rider://', '').split('/');
  if (parts[0] === 'jobs' && parts[1]) return { screen: 'job', params: { id: parts[1] } };
  if (parts[0] === 'jobs') return { screen: 'jobs', params: {} };
  if (parts[0] === 'earnings') return { screen: 'earnings', params: {} };
  return { error: 'unknown-path' };
}
Ctrl + Enter runs
Show a solution
JavaScript
const SCHEME = 'mangrove-rider://';
const HOST = 'rider.mangrove.example';

function parseDeepLink(url) {
  // One parser for both forms: turn the scheme link into the https form first
  const text = url.startsWith(SCHEME) ? 'https://' + HOST + '/' + url.slice(SCHEME.length) : url;
  let parsed;
  try {
    parsed = new URL(text);
  } catch {
    return { error: 'unknown-host' };
  }
  if (parsed.protocol !== 'https:' || parsed.hostname !== HOST) return { error: 'unknown-host' };

  // pathname has no query string; filter drops the empty pieces from slashes
  const parts = parsed.pathname.split('/').filter(Boolean);
  if (parts.length === 0 || (parts.length === 1 && parts[0] === 'jobs')) return { screen: 'jobs', params: {} };
  if (parts.length === 2 && parts[0] === 'jobs') {
    return /^\d+$/.test(parts[1]) ? { screen: 'job', params: { id: parts[1] } } : { error: 'bad-id' };
  }
  if (parts.length === 1 && parts[0] === 'earnings') return { screen: 'earnings', params: {} };
  return { error: 'unknown-path' };
}
Pass the exercise to finish this task.

Keep the link at the sign-in gate

Write gate(signedIn, href), which decides what to show when a checked link arrives, and afterSignIn(remember). href is a path such as '/jobs/48213', or null when there is no link. gate returns { show, remember }: the path to open now, and a path to open after sign-in (or null). The rules: signed-in riders get the link, or the job list '/' when there is none, and never the sign-in screen. Signed-out riders can open the public paths in PUBLIC; anything else shows '/sign-in' and remembers the link. afterSignIn returns the remembered link, or '/'. The starter is what Expo's basic example does: it forgets the link.

JavaScript exerciseRuns in your browser with tests
const PUBLIC = ['/sign-in', '/help'];

function gate(signedIn, href) {
  if (!signedIn) return { show: '/sign-in', remember: null };
  return { show: href, remember: null };
}

function afterSignIn(remember) {
  return '/';
}
Ctrl + Enter runs
Show a solution
JavaScript
const PUBLIC = ['/sign-in', '/help'];

function gate(signedIn, href) {
  if (signedIn) {
    // a signed-in rider never lands on sign-in
    if (!href || href === '/sign-in') return { show: '/', remember: null };
    return { show: href, remember: null };
  }
  if (href && PUBLIC.includes(href)) return { show: href, remember: null };
  return { show: '/sign-in', remember: href || null };   // keep the link for later
}

function afterSignIn(remember) {
  return remember || '/';
}
Pass the exercise to finish this task.

Check yourself

Imran wants two new things: a "Cash summary" that riders open from the Earnings tab at the end of a shift, and a "Report a problem" form opened from a job, which the rider fills in and sends. What structure fits best?

The backend team asks what to put in the "new job" push notification. Which payload is the best choice?

A rider scrolls the Jobs tab to job 14, opens Earnings, and comes back. Then they open a job, type a note held in useState, press back, and open the same job again. What is still there?

Flashcards

Open my review

Key takeaways

  • Stacks go deeper, tabs switch between areas used all day, and modals hold short tasks. Put detail screens above the tabs so back behaves.
  • In Expo Router the file tree is the navigation: _layout files pick the navigator, (groups) stay out of the URL, and [id] makes a dynamic route.
  • Pass ids, not objects, so every screen can rebuild itself from its URL, whether the rider came from the list, a link or a notification.
  • Send scheme links, verified https links and notification taps through one checked door, and send bad links to a safe screen.
  • Plan for a killed app and an expired session: save the rider's work and the job in progress, and keep the tapped link while they sign in.

Sources

Episode transcriptBack to the same job, every time · about 12 minutes · tap any line to play from there
  1. NadiaImran, picture a rider at a customer's gate. Eleven minutes left on a thirty-minute Now order. She switched to Google Maps to find the building, and when she comes back to the Rider app, it's on the job list, scrolled to the top. The job she was delivering is nowhere on screen.
  2. ImranSo she scrolls, finds it, taps it, and loses a minute she didn't have.
  3. NadiaAnd the next day, a new-job notification opens the list instead of the job. Today is about navigation: how screens fit together, how a link opens one exact job, and what survives when the phone closes your app.
  4. NadiaStart with a Mangrove store. The aisles are like tabs. You walk from dairy to rice and back, and dairy is how you left it, with the two yogurts you were comparing still on the shelf.
  5. ImranAnd picking things up?
  6. NadiaThat's a stack. You pick up one jar to read the label, then another, and you put them back in reverse order. When the cashier asks if you need a bag, you answer before you can go on. That question is like a modal, a short task on top of everything.
  7. ImranAnd I guess the shelf code, aisle seven, shelf three, is a deep link. Someone sends it to you and you walk straight there.
  8. NadiaExactly. And the stock room door that says staff only is the sign-in gate. A good front desk writes down where you were going, so you can go there after they let you in.
  9. ImranLet me check the stack part against the app. The rider taps a job, the detail screen goes on top of the list. She presses back, it comes off.
  10. NadiaRight. And the detail of that matters. While the detail is on top, the list stays mounted underneath, so it keeps its scroll position. The screen that comes off is unmounted. Anything held in its state is gone.
  11. ImranAnd tabs? The Rider app has Jobs and Earnings.
  12. NadiaIn React Navigation seven, which Expo Router is built on, a tab screen mounts the first time you open it and then stays mounted. So the rider checks her earnings, comes back to Jobs, and the list is exactly where she left it.
  13. ImranThen what does the Android back button do on the Earnings tab? I'd guess it closes the app.
  14. NadiaThat's the common guess, and it's wrong by default. Bottom tabs go back to the first tab first. So back from Earnings goes to Jobs, and only a second back leaves the app.
  15. ImranAnd on an iPhone there's no back button at all.
  16. NadiaNo system button. The stack draws a back arrow in the header, and the rider can swipe from the left edge. A modal slides up from the bottom and can be dragged down to close. Riders use both kinds of phone, so every screen needs a sensible place to go back to.
  17. NadiaIf you can see your screen, the first diagram shows the whole Rider app as layers. At the bottom, the two tabs. Above them, the job detail. Above that, the proof of delivery modal. Off to the side, the sign-in screen, which only signed-out riders see.
  18. ImranSo the job detail sits above the tabs, not inside the Jobs tab.
  19. NadiaYes, and that's on purpose. The tab bar hides on the detail screen, and back returns to the tab the rider came from. Expo's own docs recommend that layout.
  20. ImranNow, Expo Router. People say the file tree is the navigation. What does that mean in practice?
  21. NadiaEvery file in the app folder becomes a screen with a URL. A layout file in a folder decides whether the screens beside it form a stack or tabs. A folder whose name is in parentheses, like the tabs folder, groups screens without adding to the URL. And a file named with the word id in square brackets matches any job id.
  22. ImranSo the job detail file lives in a jobs folder, and its URL is jobs, then the number.
  23. NadiaRight. The screen reads the id from the URL with a hook called use local search params. With typed routes switched on, a typo in a route name becomes a TypeScript error before the rider ever sees it.
  24. ImranHere's where I'd cut a corner. The list already has the whole job. Why not hand the whole object to the detail screen?
  25. NadiaBecause the copy goes stale. The list loaded at two minutes past ten. Dispatch changed the drop address at five past. The detail screen shows the old address from its copy, and the rider rides to the wrong building.
  26. ImranAnd a notification wouldn't have the object anyway.
  27. NadiaExactly. Params end up in the URL, and URLs carry strings. React Navigation's own advice is the same: pass identifiers, and read the data from a cache or the API. Pass the id, and the screen can load itself from any way in.
  28. NadiaThat's the first half of what went wrong at Mangrove. The detail screen got the whole job from the list, so a notification with only an id had nothing to show. It opened the list instead.
  29. ImranAnd the second half was the rider at the gate.
  30. NadiaYes. On a phone with two or three gigabytes of memory, Android closes apps in the background to make room. Maps needed memory, so the Rider app was closed. When she came back, it started from scratch, with empty JavaScript state, on its first screen.
  31. ImranCan you stop Android doing that?
  32. NadiaNo, and iOS does the same under memory pressure. So you design for it. Anything the rider would hate to lose is saved to storage as she goes. For the Rider app, that's the job in progress. Save its id when she picks up the order, and on a cold start, open that job again.
  33. ImranAnd the half-typed note?
  34. NadiaSaved under the job's id while she types. My test for the fix was low-tech. Open a job, switch to Maps, force-stop the Rider app in settings, open it again. It must land on the same job with the note still there.
  35. ImranSo the fix is the id in the URL, plus the job in progress saved on the phone.
  36. NadiaYes. Now, deep links. There are three ways in for a rider: a scheme link, an https link, and a notification tap.
  37. ImranStart with the scheme.
  38. NadiaYou pick a scheme name, mangrove dash rider, in the app config. Then a link that starts with that name opens the app on the matching screen. It's quick, but two weak spots. If the app isn't installed, the link does nothing useful. And any other app can claim the same scheme.
  39. ImranSo the https kind fixes that.
  40. NadiaIt does. Android App Links and iOS universal links use a normal web address on a domain you own. You prove you own it with a small file on the domain that names your app and, on Android, the fingerprint of the key that signs it. If the app is missing, the same link opens a web page.
  41. ImranI've heard of teams where these links work on the developer's phone and fail for everyone else.
  42. NadiaThat's the classic. With Play App Signing, Google re-signs the app with its own key. If the file only lists your upload key's fingerprint, store installs fail verification, and since Android twelve an unverified link opens in the browser. Nothing crashes. It quietly goes to the website.
  43. ImranAnd the third way in, a notification tap.
  44. NadiaTwo cases. If the app is running, a listener fires when the rider taps. If the app wasn't running at all, the tap launches it, and you ask for the last notification response when the root layout first mounts. Miss that second case and cold-start taps go nowhere.
  45. NadiaThe second diagram shows the three ways in at the top, all feeding one box: parse and check. From there a valid link goes to the job if the rider is signed in, or to sign-in with the link saved. A bad link goes to the job list with a short notice.
  46. ImranWhy check at all? We send the links ourselves.
  47. NadiaSome of them. A dispatcher can mistype one in a chat message. An old notification can point at a job that went to another rider. Someone can try odd links to see what the app does. Treat every link like user input: check the host, the path and the id.
  48. ImranAnd the sign-in gate. Say the rider's session expired and she taps a new-job notification.
  49. NadiaExpo Router has a protected group for that. Screens inside it exist only while a condition, like signed in, is true. Otherwise the router redirects to a screen she's allowed to see. But Expo's basic sign-in example then goes to the home screen, so the job she tapped is lost.
  50. ImranUnless the front desk writes it down.
  51. NadiaExactly. Save the link before the redirect, open it after sign-in. And one more thing: the guard only decides what the app shows. The API still checks the token on every request.
  52. NadiaIn Build it, you add the scheme and the https domain to the app config, then move the screens into the file tree: two tabs, the job detail above them, and proof as a modal. The root layout holds the sign-in guard and the notification listener.
  53. ImranAnd the links all go through your parse function from Practice.
  54. NadiaYes. A special native intent file runs it on every link the system opens, and the notification hook runs it on every tap. Then you make a development build, because Expo Go only knows its own links, and fire test links at the phone from your laptop, including a bad one.
  55. ImranWhat would I ask in my next design review for the Rider app?
  56. NadiaThree good ones. If I paste a link to a job that isn't mine, what does the app show? Can every linked screen load itself from the id with an empty cache? And what does the rider lose if Android closes the app on the proof screen?
  57. NadiaHere's one to think about. A rider scrolls the job list, checks Earnings, comes back. Then she opens a job, types a note kept in component state, presses back, and opens the same job again. What's still there? Take a second.
  58. ImranThe scroll position, because the tab stayed mounted. The note is gone, because back unmounted the detail screen.
  59. NadiaThat's it. And that's why the note belongs in storage.
  60. ImranLet me sum up. One: stacks go deeper, tabs switch between areas used all day, modals hold short tasks, and detail screens sit above the tabs.
  61. ImranTwo: pass ids, not objects, so every screen can rebuild itself from a link. Three: every way in goes through one checked door, and the app plans for being killed and for an expired session.
  62. NadiaOne nuance on the third. Planning for a kill means saving while the rider works. Android gives no warning before it closes the app.
  63. ImranFair. And next time?
  64. NadiaNext time, the rider goes into a basement with no signal and still has to prove the delivery. Offline first.
Finished reading?Mark it complete to fill in its stop on your course line.

Use it at work

Loyalty card screen (React)

A phone screen with its points rules in a tested helper file.

Open in the Code Lab
Next stopOffline first
Was this lesson helpful?
Questions and discussion

Loading the discussion…