Skip to main content

ADR-0003 — Expo-managed React Native, iOS first

Date2026-08-17
Superseded by

Context

Two requirements pull against each other.

"It has to feel like a slick native iOS app." Liquid Glass, SF Symbols, native tabs, the system's own gestures and haptics. Historically that argues for Swift and SwiftUI.

"Every PR gets a build I can test from my phone by scanning a QR code." Over-the-air updates in under a minute, no TestFlight review, no Xcode. That argues hard against a pure native build, where every change is a fresh archive and an App Store Connect upload.

There is also a practical constraint: one developer, four deployables, and a documentation obligation. Two codebases is not the place to spend the budget.

Decision

The app is React Native under Expo (SDK 57), targeting iOS only, delivered to previews via EAS Update.

The bet is that modern Expo closes most of the native-feel gap:

  • expo-glass-effect gives real Liquid Glass — GlassView, with isLiquidGlassAvailable() to branch honestly when it is not there.
  • expo-router's NativeTabs renders an actual UITabBar, including minimizeBehavior="onScrollDown". Not a JS reimplementation.
  • expo-symbols gives real SF Symbols, weight-matched to the system font.
  • react-native-screens gives the real native stack and the real interactive back-swipe.
  • @expo/ui gives SwiftUI-backed components where we need them.

Scope decisions that come with it:

  • iOS only. app.config.ts keeps Android and web keys so a future decision is a small diff, but nothing is tested on them and no Android-specific workaround goes in without a new ADR.
  • deploymentTarget: '18.0', and the Liquid Glass path needs newer.
  • EAS Update for previews, on a per-PR branch, with the PR's API URL baked into the update via EXPO_PUBLIC_API_URL. See ADR-0006.
  • Native modules are pinned to the SDK. Add dependencies with expo install, not pnpm add.

Consequences

What gets harder:

  • We are downstream of Expo's release cycle. A new iOS design language lands when expo-glass-effect supports it, not when Apple ships it. For an app whose selling point is feeling current, that is the real cost of this decision.
  • The gap is closed, not eliminated. Some things will still be worse than SwiftUI. When they are, the answer is a native module, not a JavaScript approximation.
  • Adding a native module needs a new dev build, not just a reload. That has to be said in the PR description or reviewers waste an afternoon.
  • Bundle size and startup need watching. Hermes plus a large JS bundle is not free.

What we are committed to:

  • Expo's managed workflow and EAS. expo prebuild is available if we ever need to eject to bare, and doing so gives up EAS Update's convenience.
  • Recent iOS. Users on iOS 17 are not supported.

What this creates:

  • An EXPO_TOKEN secret and an EAS project in the deploy pipeline.
  • A discipline around expo install --check in CI, because version drift between the SDK and its native modules is the classic Expo failure.

Reversing it: rewriting in SwiftUI is a full rewrite of the app layer. The API, the data model, the sync protocol, and the docs all survive, which is roughly half the work. Expensive but not catastrophic — and the offline-first design means the hard part (the sync engine) is specified well enough to reimplement.

Alternatives considered

Native Swift / SwiftUI

The best possible native feel, first access to every Apple API, no framework in the way.

Rejected on the preview requirement. Getting a build onto a phone per PR means TestFlight, which means processing time and review friction — nothing like scanning a QR code and having the new build in seconds. That workflow is a stated requirement, and it is the thing that makes iterating on this project pleasant.

Genuinely the strongest alternative. If the native-feel gap turns out to be wider than expected, this is what supersedes this ADR.

React Native bare workflow

Full native control, keeps React. Rejected because it gives up EAS Update and adds an Xcode project to maintain by hand, for a level of control we have no identified need for. expo prebuild is the escape hatch if that changes.

Flutter

Good performance, one codebase. Rejected because it does not use the platform's own controls — it draws its own. That is the opposite of the requirement here. Cupertino widgets are an imitation of iOS, and imitation is what we are trying to avoid.

A web app or PWA

Cheapest by far. Rejected on offline capability, on iOS PWA limitations, and on native feel. The offline requirement alone is disqualifying.

Open questions

  • How wide is the remaining gap? Sheet detents, context menus, haptics, and Dynamic Type are the concrete tests. See the checklist in iOS native feel.
  • What is the trigger for reconsidering? Suggested: if two or more roadmap items in a phase require custom native modules to feel right, revisit.
  • Android. Not planned. Reversing the iOS-only scope needs a new ADR superseding this one, because NativeTabs, Liquid Glass and SF Symbols all need Android equivalents that are not drop-in.