Baton

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() }
        }
    }
}
Plate 1. A screen with one variable. In Swift the operation value is the view's stored property, resolved by the @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"}

Plate 2. Every phase the specification scripts, with the fetch beside it. Note the handles that read 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: IssuesQuery

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}

Plate 3. Two operations over the same character, under the store's default expiration of a minute; the second states thirty seconds of its own. As the clock advances the second reads stale first, then both; a revalidation refetches both, since both are stale by then.

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")")
            }
        }
    }
}
Plate 4. The header reads five fields of one record, one of them through a link, and gets a lens with exactly those five accessors.

The lens of CharacterHeader_character, a fragment, reads a record of type Character:

accessorreadsas
namenamea scalar, optional
statusstatusa scalar, optional
speciesspeciesa scalar, optional
imageimagea scalar, optional
originorigina lens of Location, optional
  namenamea scalar, optional
Plate 5. The lens as reported. 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() }
        }
    }
}
Plate 6. A list of lenses, a spread that may read as absent, and a field under @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) }
        }
    }
}
Plate 7. An inline fragment, the rule that takes its value, and the screen that builds the value in its body and hands it on.