Mobile Apps with React Native and Expo · Mobile Interviews and Capstone

Capstone: Build an Offline Notes App

Put the whole course together: a notes app with sign-in, a searchable list, a create/edit screen, offline-first sync, push reminders and a store-ready build. Use the checklist and rubric to review your work.

Create a free account to save this page, take notes and track your progress. Sign in

The brief

Build "Pocket Notes" with Expo and TypeScript:

  1. Sign in with email and password against an API (mock it if you don't have one), tokens in SecureStore, protected routes.
  2. Notes list (FlatList): search box, pull-to-refresh, empty state, and a details screen at /notes/[id] that also opens from a deep link.
  3. Create and edit with a validated form and a keyboard that never hides the Save button.
  4. Offline first: notes saved in SQLite, changes queued in an outbox and synced when online, with idempotent client IDs.
  5. Reminders: a local or push notification at a chosen time; tapping it opens the note.
  6. Quality: unit tests for the reducer, validation and sync queue; one component test for the editor; dark mode; accessibility labels.
  7. Ship it: a preview build with EAS Build and one over-the-air update with EAS Update.

Suggested structure:

app/
  _layout.tsx               providers: QueryClient, session, theme
  sign-in.tsx
  (app)/_layout.tsx         redirects to /sign-in without a session
  (app)/(tabs)/index.tsx    notes list
  (app)/(tabs)/settings.tsx
  (app)/notes/[id].tsx      view / edit
  (app)/notes/new.tsx
src/
  db.ts  outbox.ts  api.ts  session.tsx  notifications.ts

A head start

The heart of the app is the sync logic. Here's a compact model of the notes store with the outbox you can adapt.

Notes store: local first, outbox, searchTry it yourself
type Note = { id: string; title: string; body: string; updatedAt: number; synced: boolean };
type Op = { opId: string; noteId: string; kind: "upsert" | "delete" };

class NotesStore {
  notes = new Map<string, Note>();
  outbox: Op[] = [];
  private seq = 0;

  save(id: string, title: string, body: string, now: number) {
    this.notes.set(id, { id, title, body, updatedAt: now, synced: false });
    this.outbox.push({ opId: `op${++this.seq}`, noteId: id, kind: "upsert" });
  }

  remove(id: string) {
    this.notes.delete(id);
    this.outbox.push({ opId: `op${++this.seq}`, noteId: id, kind: "delete" });
  }

  search(q: string): string[] {
    const needle = q.toLowerCase();
    return [...this.notes.values()]
      .filter((n) => `${n.title} ${n.body}`.toLowerCase().includes(needle))
      .sort((a, b) => b.updatedAt - a.updatedAt)
      .map((n) => n.title + (n.synced ? "" : " (not synced)"));
  }

  sync(server: Set<string>) {
    for (const op of this.outbox) {
      server.add(op.opId);                           // the server dedupes by opId
      const note = this.notes.get(op.noteId);
      if (note) note.synced = true;
    }
    this.outbox = [];
  }
}

const store = new NotesStore();
store.save("n1", "Groceries", "milk, eggs, coffee", 1);
store.save("n2", "Trip ideas", "Lisbon in spring", 2);
store.save("n1", "Groceries", "milk, eggs, coffee, bread", 3);    // edited offline
store.save("n3", "Coffee beans", "try the Ethiopian roast", 4);
console.log("search 'coffee':", store.search("coffee").join(" | "));
console.log("queued operations:", store.outbox.length);
store.sync(new Set());
store.remove("n2");
console.log("after sync and a delete:", store.search("").join(" | "), "| queued:", store.outbox.length);

Output

search 'coffee': Coffee beans (not synced) | Groceries (not synced)
queued operations: 4
after sync and a delete: Coffee beans | Groceries | queued: 1

Review your work

Open each question below for the rubric. Then go further: add images to notes with expo-image-picker, or share a note via the system share sheet.

Deliverables

Sign-in and navigation: what does a strong implementation include?

Model answer

Tokens in expo-secure-store, a session context loaded at startup with a splash/loading state, an (app) route group whose layout redirects to /sign-in without a session, single-flight token refresh on 401, and full cleanup on sign-out (tokens, cached queries, local data, push token).

Rubric

Rubric: secure storage (not AsyncStorage), no flash of protected screens before redirect, refresh handled once, sign-out clears everything. Bonus: deep links to /notes/[id] work after sign-in.

Offline sync: what does a strong implementation include?

Model answer

Notes in SQLite (not one big AsyncStorage blob), edits applied locally first, an outbox of operations with client-generated IDs, flush on reconnect and app start, removal from the outbox only after server confirmation, and a clear 'not synced' indicator. A stated conflict rule (e.g. last write wins by updatedAt).

Rubric

Rubric: survives airplane mode and app restarts with no lost or duplicated notes; tests cover the queue. Weak: network-only saves, or deleting queued items before confirmation.

Quality and shipping: what does a strong implementation include?

Model answer

Unit tests for reducer/validation/outbox, a component test for the editor, accessible labels and roles, dark mode, a smooth FlatList with memoised rows, an EAS preview build installed on a phone, and an EAS Update published with a matching runtime version.

Rubric

Rubric: tests run in CI, the app passes a quick accessibility check (screen reader labels), and the OTA update reaches the preview build. Bonus: Maestro flow for sign-in → create note → see it in the list.

Open in the app to mark lessons complete, take notes and track your progress.

Was this page helpful?