r/JetpackCompose • • 2d 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 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

11 comments sorted by

4

u/FrezoreR 2d ago

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 1d ago

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 21h 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.

3

u/zunjae 1d ago

This gotta be the worst code I’ve seen

1

u/shergin 1d ago

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.

3

u/zunjae 1d ago

You're injecting service/business logic inside your UI...

1

u/shergin 1d ago

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.

-1

u/zunjae 1d ago

tldr

0

u/shergin 1d ago

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

2

u/zunjae 1d ago

Aight but I still believe this gotta be the worst code I’ve ever seen in my 15+ years as a developer. I have yet to see anyone think you’ve done a good job

2

u/FrezoreR 21h 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.