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.
The brief
Build "Pocket Notes" with Expo and TypeScript:
- Sign in with email and password against an API (mock it if you don't have one), tokens in SecureStore, protected routes.
- Notes list (
FlatList): search box, pull-to-refresh, empty state, and a details screen at/notes/[id]that also opens from a deep link. - Create and edit with a validated form and a keyboard that never hides the Save button.
- Offline first: notes saved in SQLite, changes queued in an outbox and synced when online, with idempotent client IDs.
- Reminders: a local or push notification at a chosen time; tapping it opens the note.
- Quality: unit tests for the reducer, validation and sync queue; one component test for the editor; dark mode; accessibility labels.
- 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.
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.