Baton

The guide

Lists

A connection on a refetchable fragment: pages merged in the store, loadNext on the lens, and the edge directives that add to or remove from it.

A list that pages is a Relay connection in a fragment of its own. The fragment owns the pagination, the store owns the merged pages, a mutation adds or removes an edge with a directive, and the view reads the list like any other field. This chapter is that fragment, what the store holds after each page, and the edge directives.

The fragment that owns the list#

Three directives make a connection: @connection(key:) names the list in the store; @refetchable(queryName:) gives the fragment a query of its own, which the next page is fetched with; @argumentDefinitions declares the page size and the cursor as the fragment's arguments, so the screen's query need not know them.

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 1. The connection fragment, the row it spreads, and the screen that spreads it. The screen's query never mentions first or after.

The lens of a connection reads nodes, the records in order across every page; hasNext and isLoadingNext; pageInfo with its cursors; connectionID, the key an edge directive names the list by; and loadNext(count), which fetches the next page with the fragment's query and its arguments. refetch() on the fragment's own lens fetches the first page again and replaces the list.

The lens of IssueList_repository, a fragment, reads a record of type Repository:

accessorreadsas
issuesissuesa lens of IssueConnection
  edgesedgesa lens of IssueEdge, a list, optional
    nodenodea lens of Issue, optional
      idida scalar
      issueRowthe parent's recordthe lens of IssueRow_issue
    cursorcursora scalar
  pageInfopageInfoa lens of PageInfo
    endCursorendCursora scalar, optional
    hasNextPagehasNextPagea scalar
idida scalar
Plate 2. The connection as the compiler reported it. The paging surface is generated beside these accessors.

What the store holds#

The connection lives under its key in the store, apart from the pages: a page is a slot of its own under the field's storage key, notes(first:2), and the connection record under the key, __TestNotes_notes_connection, links the edges of every page in order. A refetch replaces the merged edges with the first page; a page fetched after a cursor that is no longer the connection's end is ignored.

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 connection script, step by step: the first page, the second merged, a refetch that replaces, an edge appended by a mutation, and a page fetched after a cursor the connection has moved past, which the merge ignores and which notifies nothing.

Edge directives#

A mutation that adds to or removes from a list says so on its payload, and the store does the edit in the same batch as the payload's own records:

  • @appendEdge(connections:) and @prependEdge(connections:) on a payload field that is an edge;
  • @appendNode(connections:, edgeTypeName:) and @prependNode(connections:, edgeTypeName:) on a payload field that is a node;
  • @deleteEdge(connections:) on a payload field holding the id of the node to remove from the lists;
  • @deleteRecord on a payload field holding the id of a record to delete outright, which every list skips and every link reads as null.

The connections variable carries the connection ids the view read from its lenses, so the same mutation can add to one list or three. Nothing edits the cache by hand, and nothing has to know which screens show the list.

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. A comment appended to the issue's comments. The composer takes the connectionID the list's lens read and passes it in connections.

The server answers TestAddNote(characterId: "1", text: "Appended", connections: ["Character:1:__TestNotes_notes_connection"]) with tests/add-note-n9.json, and the environment commits it as a server batch.

tests/add-note-n9.json
{
  "data": {
    "addNote": {
      "noteEdge": {
        "cursor": "c9",
        "node": {
          "id": "n9",
          "text": "Appended"
        }
      }
    }
  }
}
A lens readsand finds
notes · character.notes.edges.2.node.textAppended
notes · character.notes.edges.3.node.textnull

The store after the step, scripts/connections-inserted.store.json

Character:1Character
__TestNotes_notes_connectionCharacter:1:__TestNotes_notes_connection
id"1"
name"Rick Sanchez"
notes(after:"c2",first:2)Character:1:notes(after:"c2",first:2)
notes(first:2)Character:1:notes(first:2)
Character:1:__TestNotes_notes_connectionNoteConnection
__connection_next_edge_index1
edges[ Character:1:notes(first:2):edges:0, Character:1:notes(first:2):edges:1, Character:1:__TestNotes_notes_connection:edges:0 ]
pageInfoCharacter:1:__TestNotes_notes_connection:pageInfo
totalCount5
Character:1:__TestNotes_notes_connection:edges:0NoteEdge
cursor"c9"
nodeNote:n9
Character:1:__TestNotes_notes_connection:pageInfoPageInfo
endCursor"c2"
hasNextPagetrue
Character:1:notes(after:"c2",first:2):edges:0NoteEdge
cursor"c3"
nodeNote:n3
Character:1:notes(after:"c2",first:2):edges:1NoteEdge
cursor"c4"
nodeNote:n4
Character:1:notes(after:"c2",first:2):pageInfoPageInfo
endCursor"c4"
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"
Note:n3Note
id"n3"
text"Get Schwifty"
Note:n4Note
id"n4"
text"Avoid the Citadel"
Note:n9Note
id"n9"
text"Appended"
client:rootQuery
character(id:"1")Character:1
node(id:"1")Character:1
client:root:mutationMutation
addNoteclient:root:mutation:addNote
client:root:mutation:addNoteAddNotePayload
noteEdgeclient:root:mutation:addNote:noteEdge
client:root:mutation:addNote:noteEdgeNoteEdge
cursor"c9"
nodeNote:n9
Plate 5. What @appendEdge leaves in the store: the new note's record, the new edge, and the connection's edge list one longer, with no refetch.

Page-based lists#

The Rick and Morty API pages by number, characters(page: 2), which is not a connection, and Baton does not pretend it is one: each page is a query of its own with the page as a variable, and the store keeps each under its storage key. A screen that shows twenty rows per page holds the current page's query; a screen that wants an endless list over such an API composes it from the pages itself; the samples show one page at a time. Page-based pagination as a concept of its own is on the watch list, with the trigger that would promote it; What earns a concept is why it is not a thirteenth word.

Keys in a lazy list#

Every lens carries its record's key, so a lazy list keys its items by it and a row keeps its state while a refetch reorders the list:

ForEach(repository.issues.nodes) { issue in
    IssueRow(issue: issue.issueRow)
}

In Swift, ForEach takes a plural lens, a Baton.List, or a connection's nodes through initializers Baton adds, keyed by the record, so no id: is needed.