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.