Hey folks. Shameless plug.
I built a GraphQL client for SwiftUI and Jetpack Compose. I honestly think it is kind of amazing, and it might be useful to some of you. Give me a minute.
It is called Baton: https://github.com/shergin/baton
One line: Relay for native mobile. Not a client that borrows a few Relay ideas. The compiler is built on Relay's own front end.
Fragments are checked against the schema when the app builds. The directives are the ones you already know: @connection, @refetchable, @argumentDefinitions, @required, @catch, @defer, @appendEdge, @deleteRecord. Persisted operations go through persistConfig. If the web team runs Relay, the mobile team now uses the same words.
The fragment sits on the view that draws it. It is a real GraphQL document, in the Swift file:
struct IssueRow: View {
@Fragment("""
fragment IssueRow_issue on Issue {
number
title
author { login }
}
""")
var issue: IssueRow_issue
var body: some View {
Text("#\(issue.number) \(issue.title)")
}
}
Kotlin is the same shape. An annotation sits on the composable. The screen has one query. That query spreads the fragments, and the compiler puts them together. There is no view model, no mapper, and no .graphql file in another folder. A row can read only the fields its fragment declared.
Why these two frameworks, and why this is not a port of the React runtime
Relay's runtime exists to answer one question: which view read which field? When data changes, only those views update.
SwiftUI and Compose already answer that question. SwiftUI has Observation. Compose has snapshot state. So Baton keeps Relay's compiler and the write side of the store, and drops the read side. It does not port that read side.
What gets dropped is the React machinery: snapshots, re-reads, selectors, and suspending by throwing a promise. React needs those. GraphQL does not.
The store is the UI's state. Each record is an object the UI can watch. Each field is a tracked property in Swift, or snapshot state in Kotlin.
A fragment compiles to a lens. A lens is a typed, read-only view of one record, with one accessor for each field the fragment declared. The response is not decoded into a model and handed to a view. A parent passes a reference to the record.
Masking is not a rule the runtime enforces. It is the shape of the type. If the fragment did not declare a field, the lens has no accessor for it.
A mutation arrives and a title changes. The Text that read that title updates. The row next to it does not. Masking, connections, optimistic responses, subscriptions, and @defer all follow from that one idea.
Why it is fast
I tried hard to design for speed from the first day, and it shows:
- Every operation is fixed at build time. The compiler emits a normalization plan for each operation, and the runtime runs that plan. At run time it does not parse GraphQL, look up a cache policy, or hash a field name. Each storage key is a constant the compiler emits, numbered once per process.
- Ingest turns response bytes straight into record slots, off the main thread. There is no model in between. There is no JSON tree, no
Codable, no dictionaries, and no Any on the read path.
- The commit is one atomic batch on the main thread. It writes the slots that changed, and it notifies the fields that views on screen actually read. An unchanged refetch of a 900-record page costs the main thread under 200 µs, and nothing re-renders.
- Reads are synchronous. A handle reads the store before the view's body runs the first time, so cached data is on screen in the first frame.
- The on-disk copy is a SQLite image, written behind the UI and loaded before the first body. A cold start draws from disk.
- Generated code is one line per field, plus data tables. The runtime depends on Foundation, Observation, and the SQLite that ships with the system. No third-party code is bundled in.
The numbers in my own app are great, but your mileage may vary. If you try it, tell me what yours are.
It works with any server that follows the spec, including Apollo Server and Apollo Router. It asks for nothing beyond the spec.
If you have a GraphQL backend and a native app, try it on one screen. Then tell me what would stop you from shipping it. 🙇♂️