The guide
Live data
Subscriptions as a stream of events into the store, deferred fragments, and what a handle does when the connection drops or the app sleeps.
Two ways data arrives after the first response: a subscription, a stream of events the server pushes, and a deferred fragment, a part of a response the server sends later. Both go through the same door as a query's response, into the same records, so a view that reads a record it names updates as after any commit.
A subscription is a stream of events into the store#
A subscription is the third kind of operation, declared like a query and resolved in a view. Its handle is open while the view lives and closed when it goes. Each event is a payload in the operation's shape, normalized at the subscription root and committed; an edge directive on the event's payload appends it to a connection, so a live list grows without a refetch.
CharacterNotes.swift
import Baton
import SwiftUI
struct CharacterNotes: View {
@Fragment("""
fragment CharacterNotes_character on Character
@refetchable(queryName: "CharacterNotesPaginationQuery")
@argumentDefinitions(count: {type: "Int", defaultValue: 10}, cursor: {type: "String"}) {
id
notes(first: $count, after: $cursor) @connection(key: "CharacterNotes_notes") {
edges { node { id text } }
}
}
""")
var character: CharacterNotes_character
@Subscription("""
subscription NoteAdded($characterId: ID!, $connections: [ID!]!) {
noteAdded(characterId: $characterId) {
noteEdge @appendEdge(connections: $connections) {
node { id text }
}
}
}
""")
var noteAdded: NoteAdded
init(character: CharacterNotes_character) {
self.character = character
noteAdded = NoteAdded(characterId: character.id ?? "", connections: [character.notes.connectionID])
}
var body: some View {
ForEach(character.notes.nodes) { note in
Text(note.text ?? "")
}
}
}CharacterNotes.kt
package app
import androidx.compose.foundation.layout.Column
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.Subscription
import baton.rememberQuery
import baton.rememberSubscription
@Fragment($$"""
fragment CharacterNotes_character on Character
@refetchable(queryName: "CharacterNotesPaginationQuery")
@argumentDefinitions(count: {type: "Int", defaultValue: 10}, cursor: {type: "String"}) {
id
notes(first: $count, after: $cursor) @connection(key: "CharacterNotes_notes") {
edges { node { id text } }
}
}
""")
@Subscription($$"""
subscription NoteAdded($characterId: ID!, $connections: [ID!]!) {
noteAdded(characterId: $characterId) {
noteEdge @appendEdge(connections: $connections) {
node { id text }
}
}
}
""")
@Composable
fun CharacterNotes(character: CharacterNotes_character) {
rememberSubscription(NoteAdded(characterId = character.id.orEmpty(), connections = listOf(character.notes.connectionID)))
Column {
for (note in character.notes.nodes) {
Text(note.text.orEmpty())
}
}
}- subscription
NoteAdded- variables
$characterId: ID!$connections: [ID!]!- the text
subscription NoteAdded($characterId:ID!){noteAdded(characterId:$characterId){noteEdge{node{id,text}}}}
The stream as a value#
The handle reads the stream as a value, never awaited: idle, parked while the app is inactive; connecting until the first event; open; waiting to reconnect after a failure, by a fixed backoff, a step doubling from one second to thirty and jittered, reset by an event; or ended, by the server's completion, by a request error the server would repeat, or by the environment's end. resumptions counts the reopenings, since events may have been missed across each; an owner that cares observes it and refetches its baseline. retry() opens a waiting or ended stream at once.
A view attaches a handle for TestNoteAdded(characterId: "1", connections: []) under storeOrNetwork, named live; the fetch stays in flight.
Stream {"handle":"live","stream":"connecting","events":0,"resumptions":0}
The stream of live delivers tests/note-added-1.json.
tests/note-added-1.json
{
"data": {
"noteAdded": {
"noteEdge": {
"cursor": "c6",
"node": {
"__typename": "Note",
"id": "n6",
"text": "Live from the garage"
}
}
}
}
}Stream {"handle":"live","stream":"open","events":1}
The store after the step, tests/note-added-1.store.json
| id | "n6" |
|---|---|
| text | "Live from the garage" |
| noteAdded(characterId:"1") | client:root:subscription:noteAdded(characterId:"1") |
|---|
| noteEdge | client:root:subscription:noteAdded(characterId:"1"):noteEdge |
|---|
| cursor | "c6" |
|---|---|
| node | Note:n6 |
The stream of live delivers tests/not-authorized.json.
tests/not-authorized.json
{
"data": null,
"errors": [
{
"message": "not authorized"
}
]
}Stream {"handle":"live","stream":"open","events":1}
The store after the step, tests/note-added-1.store.json
| id | "n6" |
|---|---|
| text | "Live from the garage" |
| noteAdded(characterId:"1") | client:root:subscription:noteAdded(characterId:"1") |
|---|
| noteEdge | client:root:subscription:noteAdded(characterId:"1"):noteEdge |
|---|
| cursor | "c6" |
|---|---|
| node | Note:n6 |
The app becomes inactive.
Stream {"handle":"live","stream":"idle","events":1,"resumptions":0}
The app becomes active.
Stream {"handle":"live","stream":"connecting","events":1,"resumptions":1}
The stream of live delivers tests/note-added-2.json.
tests/note-added-2.json
{
"data": {
"noteAdded": {
"noteEdge": {
"cursor": "c7",
"node": {
"__typename": "Note",
"id": "n7",
"text": "Second live note"
}
}
}
}
}Stream {"handle":"live","stream":"open","events":2,"resumptions":1}
A view attaches a handle for TestNoteAdded(characterId: "2", connections: []) under storeOrNetwork, named other; the fetch stays in flight.
Stream {"handle":"other","stream":"connecting","events":0}
The stream of other delivers tests/note-added-1.json.
tests/note-added-1.json
{
"data": {
"noteAdded": {
"noteEdge": {
"cursor": "c6",
"node": {
"__typename": "Note",
"id": "n6",
"text": "Live from the garage"
}
}
}
}
}Stream {"handle":"other","stream":"open","events":1}
The subscription other is completed by the server.
Stream {"handle":"other","stream":{"ended":null},"events":1,"resumptions":0}
The stream of live fails with a transport failure.
Stream {"handle":"live","stream":"waiting","events":2,"resumptions":1}
The environment's active flag is the app's to set, from the scene phase in Swift and from the process lifecycle on Android; while it is false every retained subscription is parked, and a revalidate() on the return refetches what went stale meanwhile. The Android sample's Activation and the UIKit recipe's lifecycle section are the two spellings.
The transport underneath#
Subscriptions travel through the environment's subscription transport, which is any transport with the one verb: the socket transport speaks graphql-transport-ws, and the HTTP transport reads text/event-stream for graphql-sse in its distinct-connections mode, so a server that streams over HTTP needs no socket at all. On Android the platform has no WebSocket client, so the socket is the app's own, through the baton-okhttp edge or any client that implements the protocol's few calls.
Environment(
transport: URLSessionTransport(url: api, credentials: credentials),
subscriptions: GraphQLTransportWebSocket(url: socket, credentials: credentials)
)Environment(
HttpTransport(api, credentials = credentials),
subscriptions = GraphQLTransportWebSocket(socket, client = OkHttpWebSocketClient(okHttp), credentials = credentials),
)A part that arrives later: @defer#
A spread marked @defer is a part the server may deliver in a later part of the same response. The spread's accessor is optional and nil until the fragment's fields are present, and the rest of the operation renders meanwhile; the availability check does not wait for the deferred part, and a part the store holds only half is cleared and fetched rather than read as empty.
CharacterScreen.swift
import Baton
import SwiftUI
struct CharacterScreen: View {
@Query("""
query CharacterQuery($id: ID!) {
character(id: $id) {
name
...CharacterNotes_character @defer
}
}
""")
var character: CharacterQuery
var body: some View {
if case .ready(let data) = character.phase, let character = data.character {
Text(character.name ?? "Unknown").font(.largeTitle)
if let notes = character.characterNotes {
CharacterNotes(character: notes)
} else {
ProgressView()
}
}
}
}CharacterScreen.kt
package app
import androidx.compose.foundation.layout.Column
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.Subscription
import baton.rememberQuery
import baton.rememberSubscription
@Query($$"""
query CharacterQuery($id: ID!) {
character(id: $id) {
name
...CharacterNotes_character @defer
}
}
""")
@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 ->
Text(character.name ?: "Unknown")
val notes = character.characterNotes
if (notes != null) CharacterNotes(notes) else CircularProgressIndicator()
}
}The built-in HTTP transport reads multipart/mixed for the parts, and the environment assembles each at the record its path names. A deferred part the server addresses to a place the store does not have is dropped and logged, never applied somewhere else.
What a view does not do#
A view never awaits an event, never holds a socket, and never merges a stream into its own state. It reads the record the event changed, as it reads any record, and the stream's state when it wants to show a dot. The tests drive a subscription by hand with ScriptedTransport, delivering, completing or failing events; Previews and tests shows how.
Baton