Baton

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.0

Swift 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)
        }
    }
}
Plate 1. A row declares the fields it reads, beside its body, and can read nothing else. SwiftUI on one tab, Compose on the other: the same document, the same lens.

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.

CharactersQuery
Rick SanchezAlive · Human
CharactersScreen
Morty SmithAlive · Human
CharactersScreen
Summer SmithAlive · Human
CharactersScreen
Beth SmithAlive · Human
CharactersScreen
Jerry SmithAlive · Human
CharactersScreen
Abadango Cluster PrincessAlive · Alien
CharactersScreen

The store click a field to change it

client:rootQuery
characters(page:1)
client:root:characters(page:1)Characters
results
Character:1Character
id
image
name
species
status
Character:2Character
id
image
name
species
status
Character:3Character
id
image
name
species
status
Character:4Character
id
image
name
species
status
Character:5Character
id
image
name
species
status
Character:6Character
id
image
name
species
status

0 of bodies re-ran

Nothing has changed yet.

Plate 2. The list reads the root's link and the list's links; each row reads the four fields its fragment declared, of its own record. A refetch that answers with the same bytes notifies nobody, because the store compares before it notifies.

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() }
        }
    }
}
Plate 3. The screen names the repository by its variables and spreads the row's fragment into the open issues. The row's accessor, 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}}}
Plate 4. What the compiler sends: the operation with the fragment it reaches printed after it, the 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:

accessorreadsas
numbernumbera scalar
titletitlea scalar
authorauthora lens of Actor, optional
  loginlogina scalar
Plate 5. The row's lens, as the compiler reported it: one accessor per declared field, and no accessor for anything the fragment did not declare. Masking is not a convention here; it is the shape of the type.

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

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.

No model tree

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.

No re-execution

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.

No waiting

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.