Baton

The guide

Writes

A mutation is an action; an optimistic response shows at once and is reverted on failure; nothing edits the cache by hand.

A mutation is an action: called with one argument per variable, asynchronous, able to fail, returning the payload's lens. What it changes is what its payload selects; what it adds to or removes from a list is an edge directive; what it shows before the server answers is its optimistic response. There is no cache updater, because a payload for an operation says every write.

A mutation is an action#

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 1. A star toggle. The fragment is what the button reads; the two mutations are what it does. The optimistic response is a typed value of the payload's selection, with the concrete type named because starrable is an interface.

The call is try await addStar(input:optimistic:) in Swift and addStar(input = …, optimistic = …) in a coroutine in Kotlin; isInFlight says the server has not answered. The payload selects viewerHasStarred and stargazerCount on the repository, so when the server answers, the record the button reads is updated and the button re-renders, and so does any other view reading those fields, on any screen.

The lens of StarButtonAddStar, an operation's data, reads a record of type Mutation:

accessorreadsas
addStaraddStara lens of AddStarPayload, optional
  starrablestarrablea lens of Starrable, optional
    idida scalar
    viewerHasStarredviewerHasStarreda scalar
    asRepositorythe parent's recorda lens, when the record is a Repository, optional
      stargazerCountstargazerCounta scalar
Plate 2. The action's result: the payload as a lens, with the concrete type's fields under a condition.

Optimistic responses#

An optimistic response is rendered to a payload by a generated builder, ingested like a server's response, and applied as a layer with an undo log. The layer shows in the turn of the call, on the main actor, so the star fills before the request leaves. A commit under live layers lifts them, applies the response, re-applies them, and notifies only the slots whose value differs in the end. The server's answer replaces the layer; a failure reverts it.

A view attaches a handle for TestNotesQuery(id: "1") under storeOrNetwork, named notes; the transport answers its fetch with tests/notes-page-1.json.

tests/notes-page-1.json
{
  "data": {
    "character": {
      "id": "1",
      "name": "Rick Sanchez",
      "notes": {
        "totalCount": 5,
        "edges": [
          {
            "cursor": "c1",
            "node": {
              "__typename": "Note",
              "id": "n1",
              "text": "Wubba lubba dub dub"
            }
          },
          {
            "cursor": "c2",
            "node": {
              "__typename": "Note",
              "id": "n2",
              "text": "Portal gun needs charging"
            }
          }
        ],
        "pageInfo": {
          "endCursor": "c2",
          "hasNextPage": true
        }
      }
    }
  }
}

The store after the step, tests/notes-page-1.store.json

Character:1Character
__TestNotes_notes_connectionCharacter:1:__TestNotes_notes_connection
id"1"
name"Rick Sanchez"
notes(first:2)Character:1:notes(first:2)
Character:1:__TestNotes_notes_connectionNoteConnection
edges[ Character:1:notes(first:2):edges:0, Character:1:notes(first:2):edges:1 ]
pageInfoCharacter:1:__TestNotes_notes_connection:pageInfo
totalCount5
Character:1:__TestNotes_notes_connection:pageInfoPageInfo
endCursor"c2"
hasNextPagetrue
Character:1:notes(first:2)NoteConnection
edges[ Character:1:notes(first:2):edges:0, Character:1:notes(first:2):edges:1 ]
pageInfoCharacter:1:notes(first:2):pageInfo
totalCount5
Character:1:notes(first:2):edges:0NoteEdge
cursor"c1"
nodeNote:n1
Character:1:notes(first:2):edges:1NoteEdge
cursor"c2"
nodeNote:n2
Character:1:notes(first:2):pageInfoPageInfo
endCursor"c2"
hasNextPagetrue
Note:n1Note
id"n1"
text"Wubba lubba dub dub"
Note:n2Note
id"n2"
text"Portal gun needs charging"
client:rootQuery
character(id:"1")Character:1
Plate 3. The specification's optimistic script: a rename applied as a layer, a server commit underneath it, the layer resolved, a second layer reverted, and a deleted note revived by a layer and deleted again when the layer goes.

What the view does when the server refuses is the view's: the layer is already gone, so a button reads its old state again, and the action's error says why. The Kotlin GitHub sample's comment composer empties its field at once and puts the text back on a refusal; the Swift one clears it when the server answers.

Adding to a list#

A payload field that is an edge or a node carries an edge directive naming the connections to add to, through a connections variable the view fills from its lens's connectionID:

CommentComposer.swift

import Baton
import SwiftUI

struct CommentComposer: View {
    let subjectID: String
    let connectionID: String
    @State private var text = ""

    @Mutation("""
    mutation CommentComposerAddComment($input: AddCommentInput!, $connections: [ID!]!) {
      addComment(input: $input) {
        commentEdge @appendEdge(connections: $connections) {
          node { id bodyText author { login } }
        }
      }
    }
    """)
    var addComment: CommentComposerAddComment.Action

    var body: some View {
        HStack {
            TextField("Leave a comment", text: $text)
            Button("Comment") {
                let body = text
                Task {
                    try await addComment(
                        input: AddCommentInput(body: body, subjectId: subjectID),
                        connections: [connectionID]
                    )
                    text = ""
                }
            }
            .disabled(text.isEmpty || addComment.isInFlight)
        }
    }
}
Plate 4. @appendEdge on the payload's commentEdge. The composer never touches the list; it names it.
mutation
CommentComposerAddComment
variables
$input: AddCommentInput! $connections: [ID!]!
the text
mutation CommentComposerAddComment($input:AddCommentInput!){addComment(input:$input){commentEdge{node{id,bodyText,author{__typename,login,...on Node{__isNode:__typename,id}}}}}}
Plate 5. The directive is the client's business and stays out of the text the server receives, and so does the $connections variable only it used; the request's variables still carry connections, which the server ignores.

Removing is @deleteEdge(connections:) on a field holding the removed node's id, or @deleteRecord on a field holding the id of a record to delete outright. Lists shows the edit in the store.

A payload committed by hand#

The same door a server's response comes through takes a payload from anywhere else: a REST response, a socket's tick, a preview's fixture, a test's seed. environment.commitPayload(operation, payload) runs the operation's plan over the bytes and commits them as a fetch's response is committed. A payload may carry part of what the operation selects, and it alone writes client fields.

try await environment.commitPayload(CharacterQuery(id: "1"), Payload(json: fixture))

A mutation outside any view is environment.mutate(operation, optimistic:), with the same optimistic payload.

What never to write#

  • A cache updater. No function edits the store after a mutation. What a mutation changes is what its payload selects; a list it changes is an edge directive; what it shows first is its optimistic response.
  • A model that copies a lens's fields to hold them across the call. Read the lens inside the action's closure, on the main actor; the values are a slot load away.
  • A write the server does not know about. Client fields are described in a schema extension and committed as a payload; nothing is set on a record by hand.

Porting from Relay lists the Relay write APIs that are not here and the reason for each.