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)
}
}
}@Composable
fun App(tokens: TokenStore) {
val environment = remember {
Environment(
HttpTransport("https://api.github.com/graphql", credentials = { mapOf("Authorization" to "Bearer ${tokens.current}") }),
store = Store(persistence = Persistence.named("App", version = Types.schemaDigest)),
)
}
CompositionLocalProvider(LocalBaton provides environment) {
IssuesScreen(owner = "shergin", name = "baton")
}
}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() }
}
}
}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()
}
}
}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)
}
}
}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")
}
}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() }
}
}
}IssueList.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 androidx.compose.runtime.LaunchedEffect
import baton.Fragment
import baton.Phase
import baton.Query
import baton.rememberQuery
@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 } }
}
}
""")
@Composable
fun IssueList(repository: IssueList_repository) {
val issues = repository.issues
LazyColumn {
items(issues.nodes, key = { it.id }) {
IssueRow(it.issueRow)
}
if (issues.hasNext) item {
CircularProgressIndicator()
LaunchedEffect(issues) {
runCatching { issues.loadNext() }
}
}
}
}
@Fragment("""
fragment IssueRow_issue on Issue {
number
title
}
""")
@Composable
fun IssueRow(issue: IssueRow_issue) {
Text("#${issue.number} ${issue.title}")
}
@Query($$"""
query RepositoryQuery($owner: String!, $name: String!) {
repository(owner: $owner, name: $name) {
nameWithOwner
...IssueList_repository
}
}
""")
@Composable
fun RepositoryScreen(owner: String, name: String) {
val query = rememberQuery(RepositoryQuery(owner = owner, name = name))
when (val phase = query.phase) {
is Phase.Ready -> phase.data.repository?.let { repo ->
Text(repo.nameWithOwner)
IssueList(repo.issueList)
}
Phase.Loading -> CircularProgressIndicator()
is Phase.Failed -> ErrorView(phase.error) { query.retry() }
}
}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)
}
}
}StarButton.kt
package app
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.rememberCoroutineScope
import baton.Fragment
import baton.Mutation
import baton.Phase
import baton.Query
import baton.rememberMutation
import baton.rememberQuery
import kotlinx.coroutines.launch
@Fragment("""
fragment StarButton_repository on Repository {
id
viewerHasStarred
stargazerCount
}
""")
@Mutation($$"""
mutation StarButtonAddStar($input: AddStarInput!) {
addStar(input: $input) {
starrable { id viewerHasStarred ... on Repository { stargazerCount } }
}
}
""")
@Mutation($$"""
mutation StarButtonRemoveStar($input: RemoveStarInput!) {
removeStar(input: $input) {
starrable { id viewerHasStarred ... on Repository { stargazerCount } }
}
}
""")
@Composable
fun StarButton(repository: StarButton_repository) {
val addStar = rememberMutation(StarButtonAddStar)
val removeStar = rememberMutation(StarButtonRemoveStar)
val scope = rememberCoroutineScope()
val starred = repository.viewerHasStarred
OutlinedButton(
onClick = {
scope.launch {
val id = repository.id
val count = repository.stargazerCount
if (starred) {
removeStar(
input = RemoveStarInput(starrableId = id),
optimistic = StarButtonRemoveStar.OptimisticResponse(
StarButtonRemoveStar.OptimisticResponse.RemoveStar(
StarButtonRemoveStar.OptimisticResponse.RemoveStar.Starrable(
__typename = "Repository", id = id, viewerHasStarred = false, stargazerCount = count - 1,
),
),
),
)
} else {
addStar(
input = AddStarInput(starrableId = id),
optimistic = StarButtonAddStar.OptimisticResponse(
StarButtonAddStar.OptimisticResponse.AddStar(
StarButtonAddStar.OptimisticResponse.AddStar.Starrable(
__typename = "Repository", id = id, viewerHasStarred = true, stargazerCount = count + 1,
),
),
),
)
}
}
},
enabled = !addStar.isInFlight && !removeStar.isInFlight,
) {
Text(if (starred) "★ ${repository.stargazerCount}" else "☆ ${repository.stargazerCount}")
}
}
@Query($$"""
query RepositoryQuery($owner: String!, $name: String!) {
repository(owner: $owner, name: $name) {
nameWithOwner
...StarButton_repository
}
}
""")
@Composable
fun RepositoryScreen(owner: String, name: String) {
val query = rememberQuery(RepositoryQuery(owner = owner, name = name))
val phase = query.phase
if (phase is Phase.Ready) phase.data.repository?.let { repo ->
Text(repo.nameWithOwner)
StarButton(repo.starButton)
}
}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 ^
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))val environment = Environment(SilentTransport())
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#
- One screen, end to end: what just happened, step by step, from the fragment to the next launch.
- GraphQL for mobile engineers: the schema, the document and the response, if those words are new.
- The twelve words: the whole vocabulary on one page.
- Agents: the shape of a screen and a review checklist, for a coding agent or for you.
Baton