Change log: Updated on 9/15/2022 to update to the latest Compose and Apollo Kotlin versions.
Introduction
Jetpack Compose is Android’s modern toolkit for building native UI. It simplifies and accelerates UI development on Android. Quickly bring your app to life with less code, powerful tools, and intuitive Kotlin APIs.
Apollo Kotlin is a strongly-typed, caching GraphQL client for the JVM, Android, and Kotlin multiplatform. The client will generate typesafe Kotlin models to hold data to be displayed. GraphQL and Apollo Kotlin and Jetpack Compose were destined to meet. 😄
In this post, we will show how to query a GraphQL endpoint with Apollo Kotlin and connect the response to a simple Compose UI. We will also explore how the Apollo normalized cache enables reactive scenarios that play nicely with Compose unidirectional data flow.
Getting Started
This post assumes some familiarity with Android Studio, Jetpack Compose, GraphQL and Apollo Kotlin. If you are new to any of these concepts please check out the following resources before getting started:
- Compose: https://developer.android.com/jetpack/compose/tutorial
- Android Studio: https://developer.android.com/studio
- Apollo Kotlin: https://www.apollographql.com/docs/android/tutorial/00-introduction/
- GraphQL: https://graphql.org/learn/
The code for this post is available in the compose branch of the Apollo Kotlin Tutorial. to pull this code down local open Android Studio’s terminal and run:
git clone git@github.com:apollographql/apollo-kotlin-tutorial.git
then run
git checkout compose
you can then follow the instructions for setting up a new project in Android Studio.
GraphQL
For our application data we’re going to use the full-stack tutorial GraphQL endpoint. It’s an endpoint that lists all the SpaceX missions as well as additional data concerning their launches. As a bonus feature, it also allows you to book a trip to space! You can explore and test the API using the Apollo Studio Explorer.
Let’s take a look at the different GraphQL queries and mutation schemas we need for our application. To learn more about GraphQL schemas refer to our Apollo Server documentation.
First we’ll need to query a list of launches to display in our UI, the LaunchList.graphql file contains the schema we need.
1app/src/main/graphql/com/example/rocketreserver/LaunchList.graphql23query LaunchList {4 launchConnection: launches {5 launches {6 id7 site8 isBooked9 mission {10 name11 missionPatch(size: SMALL)12 }13 }14 }15}This query fetches the launches and other data of the missions: the mission name, mission logo patch and the mission booked state. From this query, Apollo Kotlin generates the following Kotlin type-safe models.
1app/build/generated/source/apollo/service/com/example/rocketreserver/LaunchListQuery.kt2 3 public data class Data(4 public val launchConnection: LaunchConnection,5 ) : Query.Data67 public data class LaunchConnection(8 public val launches: List<Launch?>,9 )1011 public data class Launch(12 public val id: String,13 public val site: String?,14 public val isBooked: Boolean,15 public val mission: Mission?,16 public val __typename: String,17 )1819 public data class Mission(20 public val name: String?,21 public val missionPatch: String?,22 )We’ll then need to be able to update trips that are booked with a mutation. the BookTrip.graphql file contains the schema we need.
1app/src/main/graphql/com/example/rocketreserver/BookTrip.graphql23mutation BookTrip($id:ID!) {4 bookTrips(launchIds: [$id]) {5 success6 message7 launches {8 # The id is required here to lookup the correct entry in cache9 id10 # This will be written in cache and trigger an update in any watcher that is subscribed to this launch11 isBooked12 }13 }14}This mutation will update the state of a trip to booked. From this mutation, Apollo Kotlin generates the following Kotlin type-safe models.
1app/src/main/graphql/com/example/rocketreserver/BookTrip.graphql23 public data class Data(4 public val bookTrips: BookTrips,5 ) : Mutation.Data67 public data class BookTrips(8 public val success: Boolean,9 public val message: String?,10 public val launches: List<Launch?>?,11 )1213 public data class Launch(14 public val id: String,15 public val isBooked: Boolean,16 public val __typename: String,17 )Finally we’ll need one more GraphQL schema to get started on our Compose UI. We’ll need to be able to cancel trips that are previously booked with another mutation. the CancelTrip.graphql file contains the schema we need.
1app/src/main/graphql/com/example/rocketreserver/CancelTrip.graphql23mutation CancelTrip($id:ID!) {4 cancelTrip(launchId: $id) {5 success6 message7 launches {8 # The id is required here to lookup the correct entry in cache9 id10 # This will be written in cache and trigger an update in any watcher that is subscribed to this launch11 isBooked12 }13 }14}This mutation will update the state of a trip to canceled. From this mutation, Apollo Kotlin generates the following Kotlin type-safe models.
1app/src/main/graphql/com/example/rocketreserver/CancelTrip.graphql23public data class Data(4 public val cancelTrip: CancelTrip,5 ) : Mutation.Data67 public data class CancelTrip(8 public val success: Boolean,9 public val message: String?,10 public val launches: List<Launch?>?,11 )1213 public data class Launch(14 public val id: String,15 public val isBooked: Boolean,16 public val __typename: String,17 )The Compose UI
Now that we understand exactly what we need from GraphQL we can finally get to writing our Compose UI! Compose works by composing elementary functions, producing a tree of composable functions (Composables), renderable on demand.
Our approach will be to use 3 simple Composables for our UI:
- A
LaunchListthat will render all the of theLaunchItemsavailable. - A
LaunchItemwhich will contain the actual trip data. - A
BookButtonthat will let someone book or cancel a trip.
At the end the UI will look like this:

Reactive UI
Apollo Kotlin comes with a normalized cache. Cache normalization is a powerful concept; it minimizes data redundancy and because your normalized cache can de-duplicate your GraphQL data (using proper cache IDs), you can use the cache as the source of truth for populating your UI. When executing an operation, you can use the ApolloCall.watch method to automatically notify that operation whenever its associated cached data changes. You can learn more about cache normalization in this post that demystifies cache normalization. In the next section you can see that we are using the .watch method on the LaunchListQuery.

Build the Launch List
The LaunchList resides in the MainActivity.kt file. In this first section of code we will be creating a container rendering the results of the LaunchList query. The @Composable annotation indicates this is a composable function.
1app/src/main/java/com/example/rocketreserver/MainActivity.kt23@Composable4fun LaunchList() {56 val context = LocalContext.current 7 // tell Compose to remember the flow across recompositions8 val flow = remember {9 apolloClient(context).query(LaunchListQuery()).watch()10 .map {11 val launchList = it12 .data13 ?.launchConnection14 ?.launches15 ?.filterNotNull()16 if (launchList == null) {17 // There were some error18 // TODO: do something with response.errors19 UiState.Error20 } else {21 UiState.Success(launchList)22 }23 }24 .catch { e ->25 emit(UiState.Error)26 }27 }In the 2nd part of the composable we want to watch the cache to react to changes. For this we can use Coroutines Flows and the Compose collectAsState helper function. You can add this code block to bottom of code reference above. To check your work, you can refer to the MainActivity.kt file for the final code.
1app/src/main/java/com/example/rocketreserver/MainActivity.kt23// collectAsState will turn our Flow into state that can be consumed by Composables4val state = flow.collectAsState(initial = UiState.Loading)56// Display thestate7 when (val value = state.value) {8 is UiState.Success -> LazyColumn(content = {9 items(value.launchList) {10 LaunchItem(it)11 }12 })13 else -> {}14 }15}You can now execute a GraphQL query and get a typesafe Kotlin model to feed your Compose UI. With the Apollo Normalized Cache, you can have your Compose UI react and update automatically on mutations. We’ll see how that will work in the the BookTrip Button section below.
Populate the Launch Items
The LaunchItem resides in the MainActivity.kt file. In this 2nd section of code we will be creating a container for each trip. We’ll leverage the Jetpack Compose Row and Column to do this. The @Composable annotation indicates this is a composable function.
1app/src/main/java/com/example/rocketreserver/MainActivity.kt23@Composable4fun LaunchItem(launch: LaunchListQuery.Launch) {5 Row() {6 Column() {7 AsyncImage(8 modifier = Modifier9 .width(100.dp)10 .height(100.dp)11 .padding(horizontal = 12.dp, vertical = 10.dp),12 model = launch.mission?.missionPatch, contentDescription = null,13 contentScale = ContentScale.Fit14 )15 }16 Column() {17 Text(18 text = launch.mission?.name ?: "",19 fontWeight = FontWeight.Bold,20 fontSize = 20.sp,21 style = MaterialTheme.typography.subtitle1,22 modifier = Modifier23 .fillMaxWidth()24 .padding(horizontal = 6.dp, vertical = 8.dp)25 )26 Text(27 text = launch.site ?: "",28 fontWeight = FontWeight.Light,29 fontSize = 20.sp,30 style = MaterialTheme.typography.subtitle2,31 modifier = Modifier32 .fillMaxWidth()33 .padding(horizontal = 12.dp, vertical = 12.dp)34 )35 BookButton(launch.id, launch.isBooked)36 }37 }38}Build the Book Trip Button
Finally we have just one more thing to do!
The BookButton resides in the MainActivity.kt file. In this 3rd section of code we want to add a button to the LaunchItem Composable allow a user to either book or cancel a trip quickly. this button will trigger the mutations to update the isBooked state of a trip and then also update the cache. The @Composable annotation indicates this is a composable function.
1@Composable2fun BookButton(id: String, booked: Boolean) {3 val context = LocalContext.current4 Button(onClick = {56 GlobalScope.launch {7 withContext(Dispatchers.Main) {8 try {9 val mutation = if (booked) {10 CancelTripMutation(id)11 } else {12 BookTripMutation(id)13 }14 val response = apolloClient(context).mutation(mutation).execute()15 if (response.hasErrors()) {16 val text = response.errors!!.first().message17 val duration = Toast.LENGTH_SHORT18 val toast = Toast.makeText(context, text, duration)19 toast.show()20 }21 } catch (e: ApolloException) {22 val text = e.message23 val duration = Toast.LENGTH_SHORT24 val toast = Toast.makeText(context, text, duration)25 toast.show()26 }27 }28 }29 }) {30 Text(if (booked) "Cancel" else "Book")31 }32}
Wrapping it all up
At this point if things aren’t working for you, the final code for MainActivity should look like this:
1app/src/main/java/com/example/rocketreserver/MainActivity.kt23package com.example.rocketreserver45import android.os.Bundle6import android.widget.Toast7import androidx.activity.compose.setContent8import androidx.appcompat.app.AppCompatActivity9import androidx.compose.foundation.layout.*10import androidx.compose.foundation.lazy.LazyColumn11import androidx.compose.runtime.collectAsState12import androidx.compose.runtime.remember13import androidx.compose.ui.platform.LocalContext14import com.apollographql.apollo3.cache.normalized.watch15import kotlinx.coroutines.flow.catch16import kotlinx.coroutines.flow.map17import androidx.compose.foundation.lazy.items18import androidx.compose.material.Button19import androidx.compose.material.MaterialTheme20import androidx.compose.material.Text21import androidx.compose.ui.Modifier22import androidx.compose.ui.layout.ContentScale23import androidx.compose.ui.text.font.FontWeight24import androidx.compose.ui.unit.dp25import androidx.compose.ui.unit.sp26import coil.compose.AsyncImage27import com.apollographql.apollo3.exception.ApolloException28import kotlinx.coroutines.Dispatchers29import kotlinx.coroutines.GlobalScope30import kotlinx.coroutines.launch31import kotlinx.coroutines.withContext32import androidx.compose.runtime.Composable as Composable333435class MainActivity : AppCompatActivity() {3637 override fun onCreate(savedInstanceState: Bundle?) {38 super.onCreate(savedInstanceState)39 setContent { LaunchList() }40 }41}4243sealed class UiState {44 object Loading : UiState()45 object Error : UiState()46 class Success(val launchList: List<LaunchListQuery.Launch>) : UiState()47}4849@Composable50fun LaunchList() {5152 val context = LocalContext.current53 // tell Compose to remember our state across recompositions54 val state = remember {55 apolloClient(context).query(LaunchListQuery()).watch()56 .map {57 val launchList = it58 .data59 ?.launchConnection60 ?.launches 61 ?.filterNotNull()62 if (launchList == null) {63 // There were some error64 // TODO: do something with response.errors65 UiState.Error66 } else {67 UiState.Success(launchList)68 }69 }70 .catch { e ->71 emit(UiState.Error)72 }73 }74// collectAsState will turn our flow into State that can be consumed by Composables75 .collectAsState(initial = UiState.Loading)7677// Display the com.example.rocketreserver.UiState as usual78 when (val value = state.value) {79 is UiState.Success -> LazyColumn(content = {80 items(value.launchList) {81 LaunchItem(it)82 }83 })84 else -> {}85 }86}8788@Composable89fun BookButton(id: String, booked: Boolean) {90 val context = LocalContext.current91 Button(onClick = {9293 GlobalScope.launch {94 withContext(Dispatchers.Main) {95 try {96 val mutation = if (booked) {97 CancelTripMutation(id)98 } else {99 BookTripMutation(id)100 }101 val response = apolloClient(context).mutation(mutation).execute()102 if (response.hasErrors()) {103 val text = response.errors!!.first().message104 val duration = Toast.LENGTH_SHORT105 val toast = Toast.makeText(context, text, duration)106 toast.show()107 }108 } catch (e: ApolloException) {109 val text = e.message110 val duration = Toast.LENGTH_SHORT111 val toast = Toast.makeText(context, text, duration)112 toast.show()113 }114 }115 }116 }) {117 Text(if (booked) "Cancel" else "Book")118 }119}120121@Composable122fun LaunchItem(launch: LaunchListQuery.Launch) {123 Row() {124 Column() {125 AsyncImage(126 modifier = Modifier127 .width(100.dp)128 .height(100.dp)129 .padding(horizontal = 12.dp, vertical = 10.dp),130 model = launch.mission?.missionPatch, contentDescription = null,131 contentScale = ContentScale.Fit132 )133 }134 Column() {135 Text(136 text = launch.mission?.name ?: "",137 fontWeight = FontWeight.Bold,138 fontSize = 20.sp,139 style = MaterialTheme.typography.subtitle1,140 modifier = Modifier141 .fillMaxWidth()142 .padding(horizontal = 6.dp, vertical = 8.dp)143 )144 Text(145 text = launch.site ?: "",146 fontWeight = FontWeight.Light,147 fontSize = 20.sp,148 style = MaterialTheme.typography.subtitle2,149 modifier = Modifier150 .fillMaxWidth()151 .padding(horizontal = 12.dp, vertical = 12.dp)152 )153 BookButton(launch.id, launch.isBooked)154 }155 }156}What’s next!
Having a unidirectional data flow that reacts to cache changes makes it very easy to write robust UIs that always show the latest available data. I’m looking forward to seeing even more of what we can build with Compose!
As Compose becomes more of a standard for Android Developers, we expect many UI best practices will start to emerge. For example, in this post, we made the queries from the Composables, but we can also accomplish this from ViewModels or Repositories; this would account for better testing and separation of concerns.
Finally, if you have ideas about how to best integrate your GraphQL queries with Jetpack Compose, please reach out on Github; we’re looking forward to making the experience as smooth as possible and planning better support of Jetpack Compose in the Apollo Kotlin client in the future.
Happy composing!

