Relay, for SwiftUI and Compose.
A fragment beside every view, one request per screen, cached data in the first frame, and a re-render only where a field changed. You write what a view needs. The compiler assembles the screen's operation, checks every field against the schema, and emits a typed lens. The runtime turns responses into records that the UI framework itself observes.
swift package add-dependency https://github.com/shergin/baton --from 0.15.0Swift 6.2 · Kotlin 2.4 · the platform's own frameworks and SQLite, nothing vendored · one prebuilt compiler
Start here New to GraphQL? One screen, end to end Open the playground
IssueRow.swift
import Baton
import SwiftUI
struct IssueRow: View {
@Fragment("""
fragment IssueRow_issue on Issue {
number
title
author { login }
}
""")
var issue: IssueRow_issue
var body: some View {
VStack(alignment: .leading) {
Text("#\(issue.number) \(issue.title)")
Text(issue.author?.login ?? "ghost")
.foregroundStyle(.secondary)
}
}
}IssueRow.kt
package app
import androidx.compose.foundation.layout.Column
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import baton.Fragment
@Fragment($$"""
fragment IssueRow_issue on Issue {
number
title
author { login }
}
""")
@Composable
fun IssueRow(issue: IssueRow_issue) {
Column {
Text("#${issue.number} ${issue.title}")
Text(issue.author?.login ?: "ghost")
}
}A field changes. One view re-renders.#
The store below holds records the runtime wrote from a recorded response to the Rick and Morty API, and the screen beside it reads them through the fragments the compiler reported. Nothing in between is a view model. Click a field in the store and watch who notices.
The store click a field to change it
| characters(page:1) |
|---|
| results |
|---|
| id | |
|---|---|
| image | |
| name | |
| species | |
| status |
| id | |
|---|---|
| image | |
| name | |
| species | |
| status |
| id | |
|---|---|
| image | |
| name | |
| species | |
| status |
| id | |
|---|---|
| image | |
| name | |
| species | |
| status |
| id | |
|---|---|
| image | |
| name | |
| species | |
| status |
| id | |
|---|---|
| image | |
| name | |
| species | |
| status |
0 of bodies re-ran
Nothing has changed yet.
The view never sees the data layer
No query files to keep in sync, no cache-update code, no view model copying fields around. A fragment next to a body, and the data is there. A parent hands a child a lens, not data, and the child reads exactly the fields it declared.
Every claim can be checked
Reading a field through a lens equals reading the raw response at the same path, and fixtures under spec/ prove it for both runtimes. A claim of speed has a bench behind it in the repository. A view that re-rendered did so because a field it read changed, and a test can count it.
Relay's language, native's mechanisms
The directives, the conventions and the compiler's front end are Relay's, so a web team and a native team speak one language. What Relay built because React could not see who read what is replaced by what SwiftUI's Observation and Compose's snapshot state can see.
The shape of a screen#
A screen is one query. Every view on it is one fragment. The compiler assembles the query from the fragments spread into it, and nobody writes the screen's request by hand.
IssuesScreen.swift
import Baton
import SwiftUI
struct IssuesScreen: View {
@Query("""
query IssuesQuery($owner: String!, $name: String!) {
repository(owner: $owner, name: $name) {
nameWithOwner
issues(first: 20, states: OPEN) {
nodes { id ...IssueRow_issue }
}
}
}
""")
var issues: IssuesQuery
var body: some View {
switch issues.phase {
case .ready(let data):
if let repo = data.repository {
List {
ForEach(repo.issues.nodes ?? .empty) {
IssueRow(issue: $0.issueRow)
}
}
.navigationTitle(repo.nameWithOwner)
}
case .loading:
ProgressView()
case .failed(let error):
ErrorView(error) { issues.retry() }
}
}
}IssuesScreen.kt
package app
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import baton.Phase
import baton.Query
import baton.rememberQuery
@Query($$"""
query IssuesQuery($owner: String!, $name: String!) {
repository(owner: $owner, name: $name) {
nameWithOwner
issues(first: 20, states: OPEN) {
nodes { id ...IssueRow_issue }
}
}
}
""")
@Composable
fun IssuesScreen(owner: String, name: String) {
val query = rememberQuery(
IssuesQuery(owner = owner, name = name),
)
when (val phase = query.phase) {
is Phase.Ready -> phase.data.repository?.let { repo ->
LazyColumn {
item { Text(repo.nameWithOwner) }
items(repo.issues.nodes.orEmpty()) {
IssueRow(it.issueRow)
}
}
}
Phase.Loading ->
CircularProgressIndicator()
is Phase.Failed -> ErrorView(phase.error) {
query.retry()
}
}
}issueRow, is the baton: the parent hands it over and cannot read through it.- query
IssuesQuery- variables
$owner: String!$name: String!- spreads
IssueRow_issue- sent as
d8e7121ec0e10dfd0dd7c8d126e75dd1, the text's hash; the text stays on the server- the text
query IssuesQuery($owner:String!,$name:String!){repository(owner:$owner,name:$name){nameWithOwner,issues(first:20,states:OPEN){nodes{id,...IssueRow_issue}},id}}fragment IssueRow_issue on Issue{number,title,author{__typename,login,...on Node{__isNode:__typename,id}}}
id fields identity needs added, the whole text on one line and hashed to an id. Under persistConfig the request carries the id alone.The lens of IssueRow_issue, a fragment, reads a record of type Issue:
| accessor | reads | as |
|---|---|---|
number | number | a scalar |
title | title | a scalar |
author | author | a lens of Actor, optional |
login | login | a scalar |
Records, not models
A response is decoded straight into records keyed by type and id, off the main actor, with no model tree in between. The list, the detail and the cast of an episode all read the one record a character is. The store shows a response beside what it became.
Lists that page in the store
A list is a Relay connection on a refetchable fragment. The lens pages with loadNext, the store merges the pages, and a mutation adds an edge with a directive instead of a cache updater. Lists walks the fixture that proves it.
Writes you can see at once
A mutation is an action. Its optimistic response is a typed value that goes through the same ingest as a server's answer, shows in the turn of the call, and is reverted if the server refuses. Writes steps through a layer applied, resolved and reverted.
Honest data
Nullability is what the schema says. @required moves a null to where the product wants it, @catch turns a field error into a Result, and a field error survives caching. Missing data is reported and healed, never invented. Honest data has the directives.
The first frame has the data
Reads are synchronous on the main actor. A handle resolves against the store before the first body runs, and after a launch the store reads what the handle needs from its image on disk. No loading flash for a screen the app has seen. The walkthrough follows one screen to the next launch.
The compiler decides
Every operation is known at build time. Identity, aggregation, masking, storage keys, persisted ids and diagnostics are settled before the app runs, and a wrong field is a build error pointing at the character in the GraphQL text. The compiler shows one.
Twelve words#
The vocabulary is closed and short. A feature is a composition of these, or a Relay directive the compiler understands, or it does not ship.
- schemathe server's menu, checked in and compiled in
- documenta query, a mutation, a subscription or a fragment, beside a view
- directivethe only way behaviour attaches to data
- lensa typed, read-only view over one record
- recordan observable object keyed by type and id
- storeall records, owned by the main thread
- planhow a response becomes records, decided by the compiler
- operation valuean operation's variables, the thing a parent constructs
- connectiona list that pages, merged in the store
- environmentthe session: one store, one transport
- transportone verb,
send, yielding payloads - phaseloading, ready or failed, always readable
Where to go#
Start here
- Getting startedInstall, the schema, the first screen, the first fragment, the first list.
- GraphQL for mobile engineersThe menu, the order, the response with the order's shape, ids, lists, writes. No prior GraphQL needed.
- One screen, end to endFrom the fragment beside a view to the frame, the records, the one re-render and the next launch.
- PlaygroundType a fragment, read what the compiler wrote.
The guide
- The twelve wordsThe closed vocabulary, one plate each.
- ReadsQueries, fragments, lenses, phases, fetch policies.
- The storeRecords, identity, observation, roots, the image on disk.
- ListsConnections, pages merged, edge directives.
- WritesActions, optimistic responses, payloads.
- Honest dataNulls, errors, missing data, enums, scalars.
- Live dataSubscriptions and deferred fragments.
- The compilerWhat it checks, what it writes, how the build runs it.
- The environmentThe transport, the session, the log, tests.
- ComposeThe same screens in Kotlin.
Recipes and examples
- AgentsThe page a coding agent reads first.
- Every recipeTesting, the exchange, UIKit, view models, porting from Relay, the build integrations.
- The galleryEvery sample, with what the compiler found in it.
Why it is shaped this way
- VisionThree commitments and five rules.
- The principlesEight constraints, each with the failure it avoids.
- DecisionsEvery choice, with its evidence and what would reopen it.
- Faster, by skipping stepsWhat Apollo does on every response and every read that Baton does not.
Reference
- TerminologyThe vocabulary contract.
- The runtime contractWhat a runtime does, one paragraph a rule.
- The specificationThe fixtures both runtimes are held to.
- ChangelogEvery release, named for a bread.
Faster, by skipping steps#
Baton is faster than Apollo iOS and Apollo Kotlin on the same data, and the reason is not a cleverer store: the store writes at about the same speed. The reason is three steps Apollo takes on every response or every read that Baton does not take at all.
Apollo builds a model of the operation from the response's bytes, and builds it again from the store on every read. Baton decodes the bytes straight into record slots by the plan the compiler emitted, and a view reads a slot.
When any field a query read changes, Apollo re-executes the whole query and rebuilds the tree. Baton notifies the slot that changed, and only the bodies that read it run.
Apollo's store reads are asynchronous, so a warm cache still shows a loading frame. Baton's reads are synchronous on the main actor, so the first body has the data, and after a launch the image on disk has it too.
Each is work a view never asked for. How much of a frame it costs depends on the device and the data, so the head-to-heads stay in the repository, in BENCHMARKS.md, each with its revision, machine, OS and date, and docs/comparison.md is the longer argument, with the places Apollo is ahead.
The name#
In a relay, the baton is the thing that is actually handed over. Here it is the data a screen hands each view: exactly what the view asked for, nothing else. In Russian the same word, батон, is a loaf of bread, which is why the logo is a loaf and every release is named for one.
Baton