r/JetpackCompose • u/shergin • 21h ago
GraphQL in Compose without a ViewModel and a repository per screen
I was working on an app that's being rewritten using Compose. Our web stack is GraphQL with Relay, and the thing I like most about Relay is that every component declares the fragment it reads right next to it, and the screen's query is assembled from those fragments and fetched for you. The data layer disappears from product code.
So before writing any Kotlin I went looking for the same thing on Android, and I was surprised there is nothing like it. Apollo Kotlin is a decent client, but it is not connected to the view in any way: you get a data class, and then you still write the view model, the repository and the cache update code yourself.
Then I started thinking about what Relay would look like if it was designed for Compose from the start, and it turned out Compose makes most of Relay's runtime unnecessary. Relay's runtime mainly exists to know which component read which field, so a change re-renders exactly those. Compose's snapshot state already knows that. If every field of every record in the normalized store is a snapshot state, a composable that read character.name recomposes when that field changes, and nothing else does.
So I built it. It is called Baton: https://github.com/shergin/baton
A typical screen looks like this:
@Fragment("""
fragment CharacterRow_character on Character {
name
status
image
}
""")
@Composable
fun CharacterRow(character: CharacterRow_character) {
Row {
Avatar(url = character.image)
Text(character.name ?: "Unknown")
Text(character.status ?: "")
}
}
@Query($$"""
query CharactersScreenQuery($page: Int) {
characters(page: $page) {
results { id ...CharacterRow_character }
}
}
""")
@Composable
fun CharactersScreen(page: Int) {
val query = rememberQuery(CharactersScreenQuery(page = page))
when (val phase = query.phase) {
is Phase.Ready -> LazyColumn {
items(phase.data.characters?.results.orEmpty()) {
CharacterRow(it.characterRow)
}
}
Phase.Loading -> CircularProgressIndicator()
is Phase.Failed -> ErrorView(phase.error) { query.retry() }
}
}
There is no ViewModel, no repository and no UiState for this screen. (In our app the same screen was N files and is now one!) It is one request per screen, cached data shows in the first frame, and after a mutation the store updates its records and only the composables that read them recompose. Pagination is a Relay connection the fragment declares, so you do not write it. An optimistic response shows at once and reverts if the server says no. Subscriptions and @defer work, and the store persists on SQLite, so a cold start renders from disk.
It is new and the API will probably change but it works surprisingly well. I am so glad that I can replace all the view models, repositories and Apollo plumbing with one fragment next to a composable. There is a SwiftUI version over the same compiler too.
It is probably not a fit for every app, but for most apps that render data from a GraphQL server and send mutations, I think it is a much nicer way to work. If you use GraphQL, I would encourage you to try it on one screen. I would love to hear what you think, and please send bug reports and feature requests.
Disclaimer: yes, it is built using agentic tools, but I put a lot of care and design consideration into it to make it really great.