Baton

Start here

Getting started

Install, the schema, the first screen, the first fragment, and the first list. SwiftUI and Compose side by side.

Four things, in order: the package, the schema, the environment, the first screen. Then a fragment, a list and a write, which is most of what an app does. The GraphQL in every figure is compiled by the real compiler when this site is built, and the SwiftUI and Compose tabs show the same thing twice.

Install#

In Swift, add the package and put the build plugin on each target that holds GraphQL. The plugin runs the compiler over the target's sources at build time; the binary comes with the release, prebuilt for macOS and Linux.

// Package.swift
dependencies: [
    .package(url: "https://github.com/shergin/baton", from: "0.15.0"),
],
targets: [
    .executableTarget(
        name: "App",
        dependencies: [.product(name: "Baton", package: "baton")],
        plugins: [.plugin(name: "BatonPlugin", package: "baton")]
    ),
]

In Kotlin, the runtime is on Maven Central and the Gradle plugin runs the compiler as a task. The plugin fetches the same prebuilt binary the Swift plugin uses.

// build.gradle.kts
import baton.gradle.BatonGenerate

plugins {
    kotlin("multiplatform") version "2.4.20"
    id("com.shergin.baton") version "0.15.0"
}

val generateBaton = tasks.named<BatonGenerate>("generateBaton") {
    hosts.from(fileTree("src/commonMain/kotlin") { include("**/*.kt", "**/*.graphql") })
}

kotlin {
    jvm()
    sourceSets {
        commonMain {
            kotlin.srcDir(generateBaton.flatMap { it.outputDirectory })
            dependencies { implementation("com.shergin.baton:baton:0.15.0") }
        }
    }
}

Gradle has the repositories, the Android variant wiring and the plugin's properties; Bazel has the rule; the command line is the contract everything else spells.

The schema#

The compiler needs the server's schema as SDL, checked in beside the code. Download it with whatever your server offers, the server's own tooling or an introspection query, and name it in baton.json in the target's directory:

{
  "schema": "schema.graphql"
}

That is a complete configuration for a schema whose objects carry an id. Identity is configurable when they do not, custom scalars can map to your types, and persisted operations are one more key; the compiler lists them. The Kotlin target adds "kotlin": {"package": "app"} for the generated shared file.

The environment#

One environment per session, made at the root of the app and injected once. It holds the store, the transport and the image on disk. For an endpoint that takes a bearer token:

@main
struct IssuesApp: App {
    private static let tokens = TokenStore()
    @State private var environment = Baton.Environment(
        transport: URLSessionTransport(
            url: URL(string: "https://api.github.com/graphql")!,
            credentials: { ["Authorization": "Bearer \(tokens.current)"] }
        ),
        store: Store(persistence: Persistence(name: "App", version: Types.schemaDigest))
    )

    var body: some Scene {
        WindowGroup {
            NavigationStack { IssuesScreen(issues: .init(owner: "shergin", name: "baton")) }
                .environment(\.baton, environment)
        }
    }
}

Types.schemaDigest is generated: an image written under another schema starts again instead of being migrated. The credentials closure is read on every attempt, so a token the store renews reaches the next request without a new environment; on Android the image also needs a directory, Persistence.named("App", version = Types.schemaDigest, directory = cacheDir.path). A production endpoint wants a retry and a deadline around that transport, which is the exchange, a few dozen lines the app owns.

The first screen#

A screen declares one query and switches over its phase. The query is a document in a marker, right in the file; the compiler finds it there.

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 1. The screen's query and its three cases. IssuesQuery is generated from the document: a value of the two variables, which the parent constructs.

The parent constructs the operation value, IssuesQuery(owner:name:), and the view resolves it against the store when it appears: data already there renders in the first body, otherwise the environment fetches. phase is loading, ready(data) or failed(error), and retry is right there for the failed case.

The first fragment#

The screen's query already spreads IssueRow_issue, so the row has to exist. A fragment is the fields one view reads, beside that view, and the view takes the fragment's lens as its one input:

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 2. The row's fragment and its body. The parent writes IssueRow(issue: node.issueRow); issueRow is the accessor the spread compiled to.

Two rules that make the rest work: a view reads only through its own lens, and a parent never reads a child's fields. Add a field to the row's fragment and the row gets it; nothing in the screen changes.

The first list#

A list that pages is a connection on a refetchable fragment. The fragment owns the pagination, the store owns the merged pages, and the view reads them like any other field:

IssueList.swift

import Baton
import SwiftUI

struct IssueList: View {
    @Fragment("""
    fragment IssueList_repository on Repository
    @refetchable(queryName: "IssueListPaginationQuery")
    @argumentDefinitions(
      count: {type: "Int", defaultValue: 20}
      cursor: {type: "String"}
    ) {
      issues(first: $count, after: $cursor, states: OPEN)
      @connection(key: "IssueList_issues") {
        edges { node { id ...IssueRow_issue } }
      }
    }
    """)
    var repository: IssueList_repository

    var body: some View {
        let issues = repository.issues
        ForEach(issues.nodes) { issue in
            IssueRow(issue: issue.issueRow)
        }
        if issues.hasNext {
            ProgressView().task {
                try? await issues.loadNext()
            }
        }
    }
}

struct IssueRow: View {
    @Fragment("""
    fragment IssueRow_issue on Issue {
      number
      title
    }
    """)
    var issue: IssueRow_issue

    var body: some View {
        Text("#\(issue.number) \(issue.title)")
    }
}

struct RepositoryScreen: View {
    @Query("""
    query RepositoryQuery($owner: String!, $name: String!) {
      repository(owner: $owner, name: $name) {
        nameWithOwner
        ...IssueList_repository
      }
    }
    """)
    var repository: RepositoryQuery

    var body: some View {
        switch repository.phase {
        case .ready(let data):
            if let repo = data.repository {
                List { IssueList(repository: repo.issueList) }
                    .navigationTitle(repo.nameWithOwner)
            }
        case .loading:
            ProgressView()
        case .failed(let error):
            ErrorView(error) { repository.retry() }
        }
    }
}
Plate 3. The connection fragment, the row it spreads, and a screen that spreads the fragment in place of its own issues field. loadNext fetches the next page with the fragment's own query, and the store merges it in.

Lists shows what the store holds after each page, from the fixture that proves it.

The first write#

A mutation compiles to an action. Call it with one argument per variable and, when the user should see the result at once, a typed optimistic response:

StarButton.swift

import Baton
import SwiftUI

struct StarButton: View {
    @Fragment("""
    fragment StarButton_repository on Repository {
      id
      viewerHasStarred
      stargazerCount
    }
    """)
    var repository: StarButton_repository

    @Mutation("""
    mutation StarButtonAddStar($input: AddStarInput!) {
      addStar(input: $input) {
        starrable { id viewerHasStarred ... on Repository { stargazerCount } }
      }
    }
    """)
    var addStar: StarButtonAddStar.Action

    @Mutation("""
    mutation StarButtonRemoveStar($input: RemoveStarInput!) {
      removeStar(input: $input) {
        starrable { id viewerHasStarred ... on Repository { stargazerCount } }
      }
    }
    """)
    var removeStar: StarButtonRemoveStar.Action

    var body: some View {
        Button {
            let id = repository.id
            let count = repository.stargazerCount
            Task {
                if repository.viewerHasStarred {
                    try await removeStar(
                        input: RemoveStarInput(starrableId: id),
                        optimistic: .init(removeStar: .init(starrable: .init(
                            __typename: "Repository", id: id, viewerHasStarred: false, stargazerCount: count - 1
                        )))
                    )
                } else {
                    try await addStar(
                        input: AddStarInput(starrableId: id),
                        optimistic: .init(addStar: .init(starrable: .init(
                            __typename: "Repository", id: id, viewerHasStarred: true, stargazerCount: count + 1
                        )))
                    )
                }
            }
        } label: {
            Label("\(repository.stargazerCount)", systemImage: repository.viewerHasStarred ? "star.fill" : "star")
        }
        .disabled(addStar.isInFlight || removeStar.isInFlight)
    }
}

struct RepositoryScreen: View {
    @Query("""
    query RepositoryQuery($owner: String!, $name: String!) {
      repository(owner: $owner, name: $name) {
        nameWithOwner
        ...StarButton_repository
      }
    }
    """)
    var repository: RepositoryQuery

    var body: some View {
        if case .ready(let data) = repository.phase, let repo = data.repository {
            Text(repo.nameWithOwner)
            StarButton(repository: repo.starButton)
        }
    }
}
Plate 4. A star toggle: a fragment for what it reads, two mutations for what it does. The optimistic response is a typed value that is reverted if the server refuses.

Writes has the rest: edge directives for lists, payloads committed by hand, and why there is no cache updater.

When something is wrong#

Misspell a field and the build fails at the character in the GraphQL text, in the file that carries it, with the schema's suggestion:

IssueRow.swift

import Baton
import SwiftUI

struct IssueRow: View {
    @Fragment("""
    fragment IssueRow_issue on Issue {
      number
      titel
      author { login }
    }
    """)
    var issue: IssueRow_issue

    var body: some View {
        Text("#\(issue.number) \(issue.title)")
    }
}
IssueRow.swift:8:7: error: The type `Issue` has no field `titel`. Did you mean `title` or `state`? See https://relay.dev/docs/error-reference/unknown-field/
    8 |       titel
              ^
Plate 5. The compiler over a row with a typo. The position points into the string inside the Swift file, so the editor shows it on the line.

Previews and tests#

A store is filled from a payload as well as from the network, so a preview or a test gets a real store without a server and without mocking anything of Baton's:

let environment = Environment(transport: SilentTransport())
try await environment.commitPayload(IssuesQuery(owner: "shergin", name: "baton"), Payload(fixture))

Previews and tests is the catalogue: recorded transports, a held mutation to observe an optimistic layer, a rule tested with a value.

Where next#