my-library
Mobile clients
The same app three times — Swift, Flutter and React Native — as a way of finding out what the API is actually like to consume.
Why three
One would have been enough to use the library on a phone. Three exist because writing the same client in three ecosystems is the fastest way to learn each one properly, and because it surfaces API decisions that look fine from the server: anything awkward in all three is the API’s fault, not the platform’s.
| iOS | Flutter | React Native | |
|---|---|---|---|
| Repo | my-library-ios | my-library-flutter | my-library-rn |
| Language | Swift 6 | Dart 3.12 | TypeScript |
| State | @Observable | Riverpod (codegen) | TanStack Query v5 |
| HTTP | URLSession | dio | fetch |
| Models | Codable | freezed + json_serializable | zod |
| Navigation | NavigationStack | go_router | expo-router |
| Secrets | Keychain | flutter_secure_storage | expo-secure-store |
| Tests | 17 (Swift Testing) | 14 (http_mock_adapter) | 17 (Jest) |
All three follow the same shape — model → typed client → state layer → UI — and all three cover every endpoint including batch create. Each test suite runs offline against a stubbed transport, so none of them need a server or a token to pass.
The three-state update problem
The API distinguishes “leave this alone” from “clear this” (see The API). Most languages collapse both into null, so each client had to invent the distinction back:
var changes = BookChanges()
changes.pageCount = .set(412) // write
changes.isbn = .clear // JSON null
// nil → key omitted entirelyconst BookChanges(
pageCount: FieldUpdate.value(412),
isbn: FieldUpdate.clear(),
){ pageCount: 412, isbn: null } // undefined keys are stripped before sendTypeScript got it easiest, because undefined and null are already different values and JSON.stringify drops the former. Swift and Dart both needed an explicit three-case type. Every edit screen diffs the form against the loaded book and sends only the difference, so opening a form and saving doesn’t rewrite untouched columns.
One bug the API caused
Fractional seconds
Postgres timestamps serialise as 2026-07-24T01:35:12.345Z. Swift’s JSONDecoder.dateDecodingStrategy = .iso8601 rejects fractional seconds — it is ISO-8601, but not the subset that decoder accepts. All three clients now parse both forms, and each has a test pinning it. This is the kind of thing that only shows up when something other than JavaScript consumes your API.
Platform notes worth keeping
| Platform | Gotcha |
|---|---|
| React Native | jest-expo@57 is built on Jest 29. Installing Jest 30 fails with clearMocksOnScope is not a function. It also needs @react-native/jest-preset as a peer. |
| React Native | z.coerce.number() widens a zod schema’s input type to unknown, which breaks react-hook-form’s resolver generic. The form schema is all strings and converts on submit; the wire schema is typed. |
| React Native | legacy-peer-deps=true is committed in .npmrc. Expo hoists a react-dom wanting a newer React patch than the SDK pins; npm’s strict resolver refuses, Expo considers the tree correct. |
| Flutter | Ref comes from flutter_riverpod, not riverpod_annotation. And AsyncNotifier already defines update(), so the library notifier’s method is updateBook(). |
| Flutter | http_mock_adapter only matches requests with a null body unless given data: Matchers.any. Every POST/PATCH test silently failed to match until that was added. |
Shared design
- Optimistic delete. The row disappears immediately and is restored at its original index if the server refuses.
- Typed errors. Each maps HTTP status onto a closed set — unauthorized, notFound, validation, server, network, decoding — so screens switch exhaustively instead of inspecting status codes inline. The RN client also uses it to skip pointless retries: retrying a 401 only delays the message.
- Responsive by measured width. One column on phones, a grid whose column count is derived from available width on tablets and in landscape — no device-class checks.
- Tokens in the platform keystore. Never in UserDefaults, SharedPreferences or a bundled env file.
They all inherit the same gap
None of them paginate, because the API doesn’t. Each fetches the whole library and filters in memory — fine on wifi with 3,367 books, increasingly less so as either number grows.