The guide
Reads
A query per screen, a fragment per view, a lens per fragment, and a phase a view switches over. Fetch policies, refetch, retry.
A screen declares one query and reads its data through a lens; a view declares one fragment and reads through its own. This chapter is the read side: the handle a query resolves to, the phase it reads, what a parent hands a child, and the policies that decide when the network is asked.
A query per screen#
The screen's document is a query with variables, carried by a marker. The compiler turns it into an operation value, a struct of the variables in Swift and a class in Kotlin, which the parent constructs and the view resolves when it appears.
CharacterScreen.swift
import Baton
import SwiftUI
struct CharacterScreen: View {
@Query("""
query CharacterQuery($id: ID!) @cacheExpiration(seconds: 300) {
character(id: $id) { ...CharacterHeader_character }
}
""")
var character: CharacterQuery
var body: some View {
switch character.phase {
case .ready(let data):
if let character = data.character {
CharacterHeader(character: character.characterHeader)
}
case .loading:
ProgressView()
case .failed(let error):
ErrorView(error) { character.retry() }
}
}
}CharacterScreen.kt
package app
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.runtime.Composable
import baton.Phase
import baton.Query
import baton.rememberQuery
@Query($$"""
query CharacterQuery($id: ID!) @cacheExpiration(seconds: 300) {
character(id: $id) { ...CharacterHeader_character }
}
""")
@Composable
fun CharacterScreen(id: String) {
val query = rememberQuery(CharacterQuery(id = id))
when (val phase = query.phase) {
is Phase.Ready -> phase.data.character?.let { character ->
CharacterHeader(character.characterHeader)
}
Phase.Loading -> CircularProgressIndicator()
is Phase.Failed -> ErrorView(phase.error) { query.retry() }
}
}@Query macro; in Kotlin rememberQuery resolves it in the composable.Resolving means: the environment makes or shares a handle for that value, the handle checks the store, and if the store holds everything the operation selects, the first body renders it. Otherwise the environment fetches, and the body renders loading until the answer is committed. Two views that resolve equal values share one handle and one fetch.
The phase#
A handle's phase is loading, ready(data) or failed(error), always readable, never awaited. Beside it: isRefreshing, true while a fetch runs behind data; isStale, true when the data is older than the operation's expiration or was marked stale; fetch, the last fetch as a value, idle, in flight or failed with a kind. A view switches over the phase and renders each case; a retry is a call on the handle.
A view attaches a handle for TestHeaderQuery(id: "5") under storeOrNetwork, named jerry; the fetch stays in flight.
Phase {"handle":"jerry","phase":"loading","isRefreshing":false}
Fetch {"handle":"jerry","fetch":"inFlight"}
The fetch of jerry is answered with tests/character-header-5.json.
tests/character-header-5.json
{
"data": {
"character": {
"name": "Jerry Smith",
"status": "Alive",
"species": "Human",
"image": "https://rickandmortyapi.com/api/character/avatar/5.jpeg",
"origin": {
"name": "Earth (Replacement Dimension)",
"id": "20"
},
"id": "5"
}
}
}Phase {"handle":"jerry","phase":"ready","isRefreshing":false}
Fetch {"handle":"jerry","fetch":"idle"}
A view attaches a handle for TestHeaderQuery(id: "5") under storeOrNetwork, named quiet; the fetch stays in flight.
Phase {"handle":"quiet","phase":"ready","isRefreshing":false}
Fetch {"handle":"quiet","fetch":"idle"}
A view attaches a handle for TestHeaderQuery(id: "5") under storeAndNetwork, named eager; the fetch stays in flight.
Phase {"handle":"eager","phase":"ready","isRefreshing":true}
Fetch {"handle":"eager","fetch":"inFlight"}
The fetch of eager fails with a transport failure.
Phase {"handle":"eager","phase":"ready","isRefreshing":false}
Fetch {"handle":"eager","fetch":{"failed":"transport"}}
jerry refetches, and the fetch fails with a request failure.
Phase {"handle":"jerry","phase":"ready"}
Fetch {"handle":"jerry","fetch":{"failed":"request"}}
The step throws request
jerry refetches, answered with tests/character-header-5.json.
tests/character-header-5.json
{
"data": {
"character": {
"name": "Jerry Smith",
"status": "Alive",
"species": "Human",
"image": "https://rickandmortyapi.com/api/character/avatar/5.jpeg",
"origin": {
"name": "Earth (Replacement Dimension)",
"id": "20"
},
"id": "5"
}
}
}Fetch {"handle":"jerry","fetch":"idle"}
A view attaches a handle for TestHeaderQuery(id: "5") under networkOnly, named shown; the fetch stays in flight.
Phase {"handle":"shown","phase":"ready","isRefreshing":true}
Fetch {"handle":"shown","fetch":"inFlight"}
The fetch of shown is answered with tests/character-header-5.json.
tests/character-header-5.json
{
"data": {
"character": {
"name": "Jerry Smith",
"status": "Alive",
"species": "Human",
"image": "https://rickandmortyapi.com/api/character/avatar/5.jpeg",
"origin": {
"name": "Earth (Replacement Dimension)",
"id": "20"
},
"id": "5"
}
}
}Phase {"handle":"shown","phase":"ready","isRefreshing":false}
A view attaches a handle for TestHeaderQuery(id: "11") under networkOnly, named einstein; the fetch stays in flight.
Phase {"handle":"einstein","phase":"loading"}
Fetch {"handle":"einstein","fetch":"inFlight"}
The fetch of einstein is answered with tests/character-header-11.json.
tests/character-header-11.json
{
"data": {
"character": {
"name": "Albert Einstein",
"status": "Dead",
"species": "Human",
"image": "https://rickandmortyapi.com/api/character/avatar/11.jpeg",
"origin": {
"name": "Earth (C-137)",
"id": "1"
},
"id": "11"
}
}
}Phase {"handle":"einstein","phase":"ready"}
A view attaches a handle for TestHeaderQuery(id: "2") under storeAndNetwork, named morty; the fetch fails with a transport failure.
Phase {"handle":"morty","phase":{"failed":"transport"}}
Fetch {"handle":"morty","fetch":{"failed":"transport"}}
morty retries after its failure.
Phase {"handle":"morty","phase":"loading","isRefreshing":false}
Fetch {"handle":"morty","fetch":"inFlight"}
The fetch of morty fails with a malformed failure.
Phase {"handle":"morty","phase":{"failed":"malformed"}}
Fetch {"handle":"morty","fetch":{"failed":"malformed"}}
A view attaches a handle for TestHeaderQuery(id: "999") under storeOnly, named offline; the fetch stays in flight.
Phase {"handle":"offline","phase":{"failed":"missingData"}}
Fetch {"handle":"offline","fetch":"idle"}
A view attaches a handle for TestHeaderQuery(id: "11") under storeOnly, named cached; the fetch stays in flight.
Phase {"handle":"cached","phase":"ready"}
Fetch {"handle":"cached","fetch":"idle"}
A view attaches a handle for TestStrictQuery(id: "1") under storeOrNetwork, named strict; the transport answers its fetch with tests/character-name-hidden.json.
tests/character-name-hidden.json
{
"data": {
"character": {
"id": "1",
"name": null,
"species": "Human"
}
},
"errors": [
{
"message": "name hidden",
"path": [
"character",
"name"
]
}
]
}Phase {"handle":"strict","phase":{"failed":"fieldErrors"}}
A payload, tests/character-name-shown.json, is committed by hand for TestStrictQuery(id: "1"), as a preview or a test does.
tests/character-name-shown.json
{
"data": {
"character": {
"id": "1",
"name": "Rick Sanchez",
"species": "Human"
}
}
}Phase {"handle":"strict","phase":"ready"}
| A lens reads | and finds |
|---|---|
strict · character.name | Rick Sanchez |
A view attaches a handle for TestRequiredOrigin(id: "1") under storeOrNetwork, named required; the transport answers its fetch with tests/required-origin-1-null.json.
tests/required-origin-1-null.json
{
"data": {
"character": {
"id": "1",
"origin": null
}
}
}Phase {"handle":"required","phase":{"failed":"requiredField"}}
A payload, tests/required-origin-1.json, is committed by hand for TestRequiredOrigin(id: "1"), as a preview or a test does.
tests/required-origin-1.json
{
"data": {
"character": {
"id": "1",
"origin": {
"id": "L9",
"name": "Earth (C-137)"
}
}
}
}Phase {"handle":"required","phase":"ready"}
| A lens reads | and finds |
|---|---|
required · character.origin.name | Earth (C-137) |
A view attaches a handle for TestHeaderQuery(id: "9") under storeOnly, named waiting; the fetch stays in flight.
Phase {"handle":"waiting","phase":{"failed":"missingData"}}
Fetch {"handle":"waiting","fetch":"idle"}
The server answers TestHeaderQuery(id: "9") with rickandmorty/character-header-9.json, and the environment commits it as a server batch.
rickandmorty/character-header-9.json
{
"data": {
"character": {
"name": "Agency Director",
"status": "Dead",
"species": "Human",
"image": "https://rickandmortyapi.com/api/character/avatar/9.jpeg",
"origin": {
"name": "Earth (Replacement Dimension)",
"id": "20"
},
"id": "9"
}
}
}Phase {"handle":"waiting","phase":"ready","isRefreshing":false}
Fetch {"handle":"waiting","fetch":"idle"}
| A lens reads | and finds |
|---|---|
waiting · character.name | Agency Director |
A view attaches a handle for TestHeaderQuery(id: "9") under storeOnly, named waitingAgain; the fetch stays in flight.
Phase [{"handle":"waitingAgain","phase":"ready","isRefreshing":false},{"handle":"waiting","phase":"ready","isRefreshing":false}]
Fetch {"handle":"waitingAgain","fetch":"idle"}
| A lens reads | and finds |
|---|---|
waitingAgain · character.name | Agency Director |
A view attaches a handle for TestHeaderQuery(id: "3") under storeOrNetwork, named summer; the fetch fails with a transport failure.
Phase {"handle":"summer","phase":{"failed":"transport"}}
Fetch {"handle":"summer","fetch":{"failed":"transport"}}
A payload, tests/character-header-3.json, is committed by hand for TestHeaderQuery(id: "3"), as a preview or a test does.
tests/character-header-3.json
{
"data": {
"character": {
"name": "Summer Smith",
"status": "Alive",
"species": "Human",
"image": "https://rickandmortyapi.com/api/character/avatar/3.jpeg",
"origin": {
"name": "Earth (Replacement Dimension)",
"id": "20"
},
"id": "3"
}
}
}A view attaches a handle for TestHeaderQuery(id: "3") under storeOrNetwork, named summerAgain; the fetch stays in flight.
Phase [{"handle":"summerAgain","phase":"ready","isRefreshing":false},{"handle":"summer","phase":"ready","isRefreshing":false}]
Fetch {"handle":"summerAgain","fetch":{"failed":"transport"}}
| A lens reads | and finds |
|---|---|
summerAgain · character.name | Summer Smith |
ready while refreshing, the one whose refresh fails and keeps its data, and the one attached under storeOnly with nothing in the store, which fails with missing data and fetches nothing.Fetch policies#
Relay's four, chosen where a handle is attached: storeOrNetwork, the default, renders what the store has and fetches when the store cannot answer or the data is stale; storeAndNetwork renders and fetches; networkOnly shows loading until its own response, unless another holder already shows the handle; storeOnly never fetches. In Swift the policy is an argument of @Query; in Kotlin, of rememberQuery.
@Query("…", fetchPolicy: .storeAndNetwork) var issues: IssuesQueryval query = rememberQuery(IssuesQuery(owner, name), fetchPolicy = FetchPolicy.STORE_AND_NETWORK)A refetch asks the network again whatever the policy; environment.invalidate() marks everything stale and refetches what is retained; environment.revalidate() refetches what is stale or failed, for a return to the foreground. A query may state its own expiration with @cacheExpiration(seconds:), and the store may be given a default for the rest; data past it reads as stale, which a storeOrNetwork attach refetches, as a revalidation does.
A view attaches a handle for TestHeaderQuery(id: "5") under storeOrNetwork, named jerry; the transport answers its fetch with tests/character-header-5.json.
tests/character-header-5.json
{
"data": {
"character": {
"name": "Jerry Smith",
"status": "Alive",
"species": "Human",
"image": "https://rickandmortyapi.com/api/character/avatar/5.jpeg",
"origin": {
"name": "Earth (Replacement Dimension)",
"id": "20"
},
"id": "5"
}
}
}Phase {"handle":"jerry","phase":"ready","isStale":false}
A view attaches a handle for TestFreshCharacter(id: "5") under storeOnly, named unknown; the fetch stays in flight.
Phase {"handle":"unknown","phase":"ready","isStale":true}
Fetch {"handle":"unknown","fetch":"idle"}
A view attaches a handle for TestFreshCharacter(id: "5") under storeOrNetwork, named fresh; the transport answers its fetch with tests/character-header-5.json.
tests/character-header-5.json
{
"data": {
"character": {
"name": "Jerry Smith",
"status": "Alive",
"species": "Human",
"image": "https://rickandmortyapi.com/api/character/avatar/5.jpeg",
"origin": {
"name": "Earth (Replacement Dimension)",
"id": "20"
},
"id": "5"
}
}
}Phase {"handle":"fresh","phase":"ready","isStale":false}
The store's clock advances by 45 seconds.
Phase [{"handle":"jerry","phase":"ready","isStale":false},{"handle":"fresh","phase":"ready","isStale":true}]
The store's clock advances by 30 seconds.
Phase [{"handle":"jerry","phase":"ready","isStale":true},{"handle":"fresh","phase":"ready","isStale":true}]
The environment revalidates: what is stale or failed refetches.
Phase [{"handle":"jerry","phase":"ready","isStale":true,"isRefreshing":true},{"handle":"fresh","phase":"ready","isStale":true,"isRefreshing":true}]
Fetch [{"handle":"jerry","fetch":"inFlight"},{"handle":"fresh","fetch":"inFlight"}]
A fragment per view#
A view declares the fields it reads and takes the fragment's lens. The lens is the fragment compiled: one accessor per declared field, typed as the schema types it, over the record the parent handed over.
CharacterHeader.swift
import Baton
import SwiftUI
struct CharacterHeader: View {
@Fragment("""
fragment CharacterHeader_character on Character {
name
status
species
image
origin { name }
}
""")
var character: CharacterHeader_character
var body: some View {
VStack {
Avatar(url: character.image)
Text(character.name ?? "Unknown").font(.largeTitle)
Text("\(character.status ?? "?") · \(character.species ?? "?")")
if let origin = character.origin {
Text("From \(origin.name ?? "somewhere")")
}
}
}
}CharacterHeader.kt
package app
import androidx.compose.foundation.layout.Column
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import baton.Fragment
@Fragment("""
fragment CharacterHeader_character on Character {
name
status
species
image
origin { name }
}
""")
@Composable
fun CharacterHeader(character: CharacterHeader_character) {
Column {
Avatar(url = character.image)
Text(character.name ?: "Unknown", style = MaterialTheme.typography.headlineLarge)
Text("${character.status ?: "?"} · ${character.species ?: "?"}")
character.origin?.let { origin ->
Text("From ${origin.name ?: "somewhere"}")
}
}
}The lens of CharacterHeader_character, a fragment, reads a record of type Character:
| accessor | reads | as |
|---|---|---|
name | name | a scalar, optional |
status | status | a scalar, optional |
species | species | a scalar, optional |
image | image | a scalar, optional |
origin | origin | a lens of Location, optional |
name | name | a scalar, optional |
origin is a nested lens over the linked record; nothing the fragment did not declare exists on the type.A parent spreads the fragment and hands the child its lens through the spread's accessor; the accessor is named after the fragment, characterHeader for CharacterHeader_character, or by @alias(as:). The parent cannot read the child's fields through it, and should not try: a view that reads another view's fields grows a dependency nobody declared.
Reading through the lens#
Every accessor is a synchronous read on the main thread that loads a slot and registers the read with the UI framework. Reading character.name costs what reading an @Observable property costs. A body that read a field depends on that field of that record, and on nothing else; the store is where the notification side is.
What an accessor reads as follows the schema: a nullable field is an optional; a non-null field is not; a list of nullable elements is a list of optionals; a list of records is a collection of lenses, List in Swift and LensList in Kotlin, each lens keyed by its record. Interfaces and unions add an accessor per concrete type, asIssue, and a spread inside an inline fragment on them carries @alias, as Relay requires.
RepositoryScreen.swift
import Baton
import SwiftUI
struct IssueRow: View {
@Fragment("""
fragment IssueRow_issue on Issue {
number
title
author @required(action: LOG) { login }
}
""")
var issue: IssueRow_issue
var body: some View {
Text("#\(issue.number) \(issue.title) by \(issue.author.login)")
}
}
struct RepositoryScreen: View {
@Query("""
query RepositoryQuery($owner: String!, $name: String!) {
repository(owner: $owner, name: $name) @catch {
nameWithOwner
description
issues(first: 5, states: OPEN) {
nodes { id ...IssueRow_issue }
}
}
}
""")
var repository: RepositoryQuery
var body: some View {
switch repository.phase {
case .ready(let data):
switch data.repository {
case .failure(let errors):
Text("Could not load the repository: \(errors.description)")
case .success(nil):
Text("No such repository")
case .success(let repo?):
List {
Text(repo.nameWithOwner).font(.headline)
if let description = repo.description { Text(description) }
ForEach(repo.issues.nodes ?? .empty) { issue in
// An issue whose author is gone reads as no row.
if let row = issue.issueRow { IssueRow(issue: row) }
}
}
}
case .loading:
ProgressView()
case .failed(let error):
ErrorView(error) { repository.retry() }
}
}
}RepositoryScreen.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.Fragment
import baton.Phase
import baton.Query
import baton.rememberQuery
@Fragment("""
fragment IssueRow_issue on Issue {
number
title
author @required(action: LOG) { login }
}
""")
@Composable
fun IssueRow(issue: IssueRow_issue) {
Text("#${issue.number} ${issue.title} by ${issue.author.login}")
}
@Query($$"""
query RepositoryQuery($owner: String!, $name: String!) {
repository(owner: $owner, name: $name) @catch {
nameWithOwner
description
issues(first: 5, states: OPEN) {
nodes { id ...IssueRow_issue }
}
}
}
""")
@Composable
fun RepositoryScreen(owner: String, name: String) {
val query = rememberQuery(RepositoryQuery(owner = owner, name = name))
when (val phase = query.phase) {
is Phase.Ready -> {
val caught = phase.data.repository
val failure = caught.exceptionOrNull()
val repo = caught.getOrNull()
when {
failure != null -> Text("Could not load the repository: ${failure.message}")
repo == null -> Text("No such repository")
else -> LazyColumn {
item { Text(repo.nameWithOwner) }
repo.description?.let { item { Text(it) } }
items(repo.issues.nodes.orEmpty(), key = { it.id }) { issue ->
// An issue whose author is gone reads as no row.
issue.issueRow?.let { IssueRow(it) }
}
}
}
}
Phase.Loading -> CircularProgressIndicator()
is Phase.Failed -> ErrorView(phase.error) { query.retry() }
}
}@catch; Honest data covers the last two.Refetch, retry, invalidate#
refetch()on a handle sends the operation again and commits the answer; the data stays while it runs, and a body wakes only if something changed.retry()after a failure: with data behind the failure it is a refetch; after any other failure the phase reads loading again.refetch()on a refetchable fragment's lens runs the fragment's own query with its arguments and updates the records in place; new arguments replace the lens.environment.fetch(operation)fetches by hand, outside any view, for a refresh of rows by id or a prefetch; Discover once is the pattern.
Lookups#
A root field that names a record by id, node(id:) or character(id:), can be answered from the store before the network: baton.json declares the lookup, and the availability check binds the field to the record it already holds, so a detail screen opened from a list renders in the first frame when the list fetched everything the detail selects, and is fetched whole otherwise. The binding happens in the check, never in a read, so a read stays a slot load.
{
"schema": "schema.graphql",
"lookups": [
{ "field": "Query.character", "type": "Character", "argument": "id" },
{ "field": "Query.node", "argument": "id" }
]
}Values, when a lens will not do#
A rule outside a view, a share sheet, a comparison off the main thread: these want a value, not a live lens. A fragment marked @inline compiles to a plain value of its fields, built from the record when the spread's accessor is called, and a test builds one with its initializer. A fragment is a lens or a value, never both.
Rules.swift
import Baton
import SwiftUI
struct Rules {
@Fragment("""
fragment CharacterValue_character on Character @inline {
id
name
status
episode { id }
}
""")
var character: CharacterValue_character
}
func isSeriesRegular(_ character: CharacterValue_character) -> Bool {
character.status == "Alive" && character.episode.count > 10
}
struct CharacterScreen: View {
@Query("""
query CharacterQuery($id: ID!) {
character(id: $id) {
name
...CharacterValue_character
}
}
""")
var character: CharacterQuery
var body: some View {
if case .ready(let data) = character.phase, let character = data.character {
// The spread's accessor builds the value from the record, now.
let value = character.characterValue
Text(character.name ?? "Unknown")
if isSeriesRegular(value) { Text("A series regular") }
Button("Share") { share(value) }
}
}
}Rules.kt
package app
import androidx.compose.material3.Button
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import baton.Fragment
import baton.Phase
import baton.Query
import baton.rememberQuery
@Fragment("""
fragment CharacterValue_character on Character @inline {
id
name
status
episode { id }
}
""")
object Rules
fun isSeriesRegular(character: CharacterValue_character): Boolean =
character.status == "Alive" && character.episode.size > 10
@Query($$"""
query CharacterQuery($id: ID!) {
character(id: $id) {
name
...CharacterValue_character
}
}
""")
@Composable
fun CharacterScreen(id: String) {
val query = rememberQuery(CharacterQuery(id = id))
val phase = query.phase
if (phase is Phase.Ready) phase.data.character?.let { character ->
// The spread's getter builds the value from the record, now.
val value = character.characterValue
Text(character.name ?: "Unknown")
if (isSeriesRegular(value)) Text("A series regular")
Button(onClick = { share(value) }) { Text("Share") }
}
}
Baton