Baton

The guide

Compose

The same screens in Kotlin: where the marker stands, what differs in spelling, and the Gradle setup.

The Kotlin runtime is the same machine written for its platform: the same compiler, the same plans, the same fixtures under spec/, in Kotlin's words. The figures on this site show the Compose spelling beside the SwiftUI one; this page is what differs, and the setup.

Where the marker stands#

In Swift the marker is a macro on a stored property; in Kotlin it is an annotation on the composable that renders, holds or acts. A fragment's composable takes the lens as a parameter; a query's composable resolves the operation value with rememberQuery; a mutation's takes its action from rememberMutation. The markers repeat, so one composable may carry a fragment and two mutations.

StarButton.kt

package app

import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.rememberCoroutineScope
import baton.Fragment
import baton.Mutation
import baton.Phase
import baton.Query
import baton.rememberMutation
import baton.rememberQuery
import kotlinx.coroutines.launch

@Fragment("""
    fragment StarButton_repository on Repository {
      id
      viewerHasStarred
      stargazerCount
    }
    """)
@Mutation($$"""
    mutation StarButtonAddStar($input: AddStarInput!) {
      addStar(input: $input) {
        starrable { id viewerHasStarred ... on Repository { stargazerCount } }
      }
    }
    """)
@Mutation($$"""
    mutation StarButtonRemoveStar($input: RemoveStarInput!) {
      removeStar(input: $input) {
        starrable { id viewerHasStarred ... on Repository { stargazerCount } }
      }
    }
    """)
@Composable
fun StarButton(repository: StarButton_repository) {
    val addStar = rememberMutation(StarButtonAddStar)
    val removeStar = rememberMutation(StarButtonRemoveStar)
    val scope = rememberCoroutineScope()
    val starred = repository.viewerHasStarred
    OutlinedButton(
        onClick = {
            scope.launch {
                val id = repository.id
                val count = repository.stargazerCount
                if (starred) {
                    removeStar(
                        input = RemoveStarInput(starrableId = id),
                        optimistic = StarButtonRemoveStar.OptimisticResponse(
                            StarButtonRemoveStar.OptimisticResponse.RemoveStar(
                                StarButtonRemoveStar.OptimisticResponse.RemoveStar.Starrable(
                                    __typename = "Repository", id = id, viewerHasStarred = false, stargazerCount = count - 1,
                                ),
                            ),
                        ),
                    )
                } else {
                    addStar(
                        input = AddStarInput(starrableId = id),
                        optimistic = StarButtonAddStar.OptimisticResponse(
                            StarButtonAddStar.OptimisticResponse.AddStar(
                                StarButtonAddStar.OptimisticResponse.AddStar.Starrable(
                                    __typename = "Repository", id = id, viewerHasStarred = true, stargazerCount = count + 1,
                                ),
                            ),
                        ),
                    )
                }
            }
        },
        enabled = !addStar.isInFlight && !removeStar.isInFlight,
    ) {
        Text(if (starred) "★ ${repository.stargazerCount}" else "☆ ${repository.stargazerCount}")
    }
}

@Query($$"""
    query RepositoryQuery($owner: String!, $name: String!) {
      repository(owner: $owner, name: $name) {
        nameWithOwner
        ...StarButton_repository
      }
    }
    """)
@Composable
fun RepositoryScreen(owner: String, name: String) {
    val query = rememberQuery(RepositoryQuery(owner = owner, name = name))
    val phase = query.phase
    if (phase is Phase.Ready) phase.data.repository?.let { repo ->
        Text(repo.nameWithOwner)
        StarButton(repo.starButton)
    }
}
Plate 1. One composable hosting the fragment it renders and the two mutations it calls.

A document with a variable is written in a $$"""…""" raw string, because $ starts a template in a plain one and GraphQL's variables need it; a document without a variable may be a plain string. The compiler reads the string dedented, so it equals the .graphql file an author would write.

The same words, Kotlin's shapes#

SwiftKotlin
The document@Fragment("""…""") on a stored property@Fragment($$"""…""") on the composable
A query@Query(…) var issue: IssueQuery, whose value the parent passesval issue = rememberQuery(IssueQuery(id = id))
A mutation@Mutation(…) var addComment: CommentComposerAddComment.Actionval addComment = rememberMutation(CommentComposerAddComment)
Calling ittry await addComment(input: …, connections: [id], optimistic: …)addComment(input = …, connections = listOf(id), optimistic = …) in a coroutine
A subscription@Subscription(…) var noteAdded: NoteAddedrememberSubscription(NoteAdded(…))
The phase.loading, .ready(data), .failed(error)Phase.Loading, Phase.Ready(data), Phase.Failed(error)
@catchA Result<T, FieldErrors>A kotlin.Result<T> whose failure is a FieldErrors
@required(action: THROW)A get throws accessorA getter that throws RequiredFieldError
A plural linkBaton.List, a collection of lenses identifiable by recordLensList, a List of lenses; key items by recordID.key
The next pagetry await issues.loadNext() in a Taskissues.loadNext() in a coroutine
A retentionRetention, released with its holderHold, released by release(), since Kotlin has no deinit
An unknown enum value.unknown(String)Undeclared(scalarText)
ObservationObservation, through the record's registrarCompose snapshot state, one channel per slot read
The store's threadThe main actorThe thread that made the store; the environment's main dispatcher

Everything else is the same by construction: the same operation text, the same records, the same notifications, the same phases, proven by the same fixtures run through code the compiler generates for Kotlin.

IssuesScreen.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.Phase
import baton.Query
import baton.rememberQuery

@Query($$"""
    query IssuesQuery($owner: String!, $name: String!) {
      repository(owner: $owner, name: $name) {
        nameWithOwner
        issues(first: 20, states: OPEN) {
          nodes { id ...IssueRow_issue }
        }
      }
    }
    """)
@Composable
fun IssuesScreen(owner: String, name: String) {
    val query = rememberQuery(
        IssuesQuery(owner = owner, name = name),
    )
    when (val phase = query.phase) {
        is Phase.Ready -> phase.data.repository?.let { repo ->
            LazyColumn {
                item { Text(repo.nameWithOwner) }
                items(repo.issues.nodes.orEmpty()) {
                    IssueRow(it.issueRow)
                }
            }
        }
        Phase.Loading ->
            CircularProgressIndicator()
        is Phase.Failed -> ErrorView(phase.error) {
            query.retry()
        }
    }
}
Plate 2. The README's screen in Compose. rememberQuery holds the retention while the composable stays and releases it when the composable leaves.

The environment in a composition#

// From the GitHub sample: Exchange is the sample's own wrapper, under
// kotlin/samples/exchange; on Android the image also takes a directory.
val environment = remember(token) {
    Environment(
        Exchange(HttpTransport("https://api.github.com/graphql", credentials = { mapOf("Authorization" to "Bearer $token") })),
        store = Store(persistence = Persistence.named("GitHubTriage", version = Types.schemaDigest)),
    )
}
CompositionLocalProvider(LocalBaton provides environment) { Triage() }

The store belongs to the thread that made it and its entry points check the caller's; in a composition that is the composition's dispatcher. A test outside a composition passes one test dispatcher as both the main and the ingest dispatcher and runs under runTest.

Android#

  • The runtime starts at API 23 and needs no R8 or ProGuard rules: no reflection, no serialization, no lookup by name.
  • The runtime holds no Context, so an image named is placed by the app: Persistence.named(name, directory = cacheDir.path).
  • The platform has no WebSocket client, so a subscription over a socket goes through baton-okhttp's OkHttpWebSocketClient or an app's own client.
  • The process lifecycle tells the environment its activity: the sample's Activation observes ProcessLifecycleOwner, sets isActive and calls revalidate() on the return.
  • An Android application module adds the generated directory through the variant API; Gradle has the lines.

The build#

The runtime is com.shergin.baton:baton on Maven Central, with baton-testing, baton-okhttp and baton-inspector beside it, and the Gradle plugin com.shergin.baton runs the compiler as a task with every input and output declared, fetching the release's prebuilt binary on first use. Gradle is the page; the Kotlin runtime is the tree's own README, with the layout, the dependencies and how it is built and published.

Without Compose UI#

A view model behind Android Views, a desktop window, a service: each asks the environment for the handle, holds the retention, and reads what it shows through snapshotFlow, which emits again when a field it read changed. Views and view models is the recipe, with the code the runtime's tests compile.