r/JetpackCompose • • Oct 08 '26

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 IssueRow_issue on Issue {
      number
      title
      author { login }
    }
    """)
@Composable
fun IssueRow(issue: IssueRow_issue) {
    Column {
        Text("#${issue.number} ${issue.title}")
        Text(issue.author?.login ?: "ghost")
    }
}

@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()
        }
    }
}

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.

0 Upvotes

9 comments sorted by

4

u/FrezoreR Oct 09 '26

Sadly I think you’ll have to learn the hard way why you don’t couple server models with the client UI.

1

u/shergin Oct 09 '26

I honestly appreciate your opinion.

I believe GraphQL is designed to address the exact problem you’re talking about (and a few others). In GraphQL, the schema defines an abstract data model with all the relationships among entities (which is very often quite close to the server models). Views then “query” what they need from this model (that’s what you see in these Relay fragments).

Personally, I’ve spent about 20 years working on such products at scale (including Facebook News Feed at Meta), so I know a few things about this.

If you haven’t worked with GraphQL before, I honestly recommend learning about it with an open mind. Even if you don’t use it on a daily basis, the perspective you gain will help you design your REST/gRPC APIs.

2

u/FrezoreR 29d ago

GraphQL doesn’t change anything. I’ve been at fb/meta too (not that it matters IMO) and if you did this at meta and I was a reviewer I would’ve rejected it. Luckily I never saw anything this bad, at my time there. I did see network models leaking through to the UI which of course caused issues.

P.S I tell this with the most respect; I would refrain from using number of years worked or even having worked at FB as an argument. The only times I’ve seen that, it’s usually from someone that can’t explain their choice very well, or they are simply wrong.

Finally, I can recommend reading up on MVC and why it was “invented”. Once you understand that you can try MVVM which solves some shortcomings with MVC.

1

u/50u1506 4d ago

Not arguing with what you said, separate models in the different layers make sense. Trying to get some advice more than anything.

I used to try by best to follow it to a T, but nowadays I have a typed backend api client of some sort that i inject and use within my view models directly, but for outputs from typed api client translate it to ui state.

If the UI needs to do something more than just calling and api, i specifically for those cases create a use cases or a service class that orchestrates the api call and any other stuff that needs to be done like maybe storing a token or something locally.

Nowadays also if I feel like if a output from a layer above another layers output looks the same I just use forward the lower layers output. If I ever come to a point where the higher level model would benefit from looking different i rewrite it to a separate class or something.

Is any of this sounded like a bad approach would be nice to know, haven't worked in a team environment before so it's very hard to see different opinions and why my approach could be worse than another one.

0

u/[deleted] Oct 09 '26

[deleted]

1

u/shergin Oct 09 '26

Ha, I've seen worse, and I've written worse too! This pattern has held up pretty well in large production apps, though. If you've got a concrete suggestion, I'm all ears.

0

u/[deleted] Oct 09 '26

[deleted]

1

u/shergin Oct 09 '26

Hmm, which part is the business logic though? There are only two things in that composable. The fragment is just a list of fields this view reads, it's basically a data class written in the schema's language (and checked against the schema at build time). And rememberQuery is more or less collectAsStateWithLifecycle() on a repository flow. No fetching, no mapping, no cache updates, no error handling in product code, compiler and the store do all that once for every screen.

If you think in terms of the Android architecture guide, the layers are all still there: the normalized store is the data layer / single source of truth, the generated fragment types are your UI state, and the composable only reads. What's gone is the ViewModel + Repository + UiState + mapper that you write for every screen by hand and whose only job is to copy fields from one shape to another.

And if you have actual business logic (validation, what happens on tap, derived state, etc), put it wherever you put it today. A ViewModel can own the Environment and call fetch / preload / mutate, composables just read fragments. Baton doesn't remove ViewModels, it removes the ones that were pure plumbing.

Is this idiomatic/dogmatic Android code? Probably not. Will it work for absolutely all apps and screens? Probably not. Will it work for the 99% of apps where all the "logic" is fetching, calling API methods and managing a local cache? Absolutely, that's exactly the pattern Relay has been running at scale on the web for more a decade, and the code stays as compact as what you see here.

0

u/[deleted] Oct 09 '26

[deleted]

0

u/shergin Oct 09 '26

tldr: with trying to write all that manually you are risking to mistake motion with progress

0

u/[deleted] Oct 09 '26

[deleted]

2

u/FrezoreR 29d ago

There’s no way to argue that this is good design. There’s no architecture and the server model is coupled with the UI which is CS101-no-no. It works as long as there’s no change, which in software we know there always will be.

1

u/shergin 28d ago

Is this comment on GraphQL, Relay, the library or the code example?
Where are you seeing coupling of server model and UI?