Android apps in Compose¶
The library has no Compose dependency, and nothing in it returns Compose State: every value it
keeps current for an app, the status of syncing, a live read's rows, the queue, is a Flow or a
StateFlow, so a Compose screen collects it as it collects any other. This page is the few
decisions that come with that: where to collect, what to draw before the first value, and how the
sample turns the status into one banner.
Collect in a ViewModel, read in the screen¶
Each of the sample's screens has a ViewModel that combines what the screen needs into one value,
and the screen collects that one StateFlow:
From the samples: PhotosViewModel.kt · PhotosScreen.kt
/** The screen's state. */
val uiState: StateFlow<PhotosUiState> =
combine(
shownCount.flatMapLatest { count ->
repository.photoPage(count).reportingFailures(failures).map { count to it }
},
repository.syncStatus,
) { (count, snapshot), status ->
val online = status.network == AppNetworkStatus.Online
val photos = snapshot.value
if (photos == null) {
PhotosUiState.NoDataYet(isLoading = online)
} else {
// A page counts the server's photos; the pending ones come on top of it.
val stored = photos.count { it.pendingUpload == null }
PhotosUiState.Loaded(photos, online, hasMore = stored >= count)
}
}
.stateIn(
viewModelScope,
SharingStarted.WhileSubscribed(5_000),
PhotosUiState.NoDataYet(isLoading = true),
)
/** The Photos destination: the grid wired to its ViewModel. */
@Composable
fun PhotosRoute(
onOpenPhoto: (photoId: String) -> Unit,
viewModel: PhotosViewModel = viewModel(factory = PhotosViewModel.Factory),
) {
val state by viewModel.uiState.collectAsStateWithLifecycle()
PhotosScreen(
state = state,
onOpenPhoto = onOpenPhoto,
loadPhotoFile = viewModel::photoFile,
onShowMore = viewModel::showMore,
)
}
collectAsStateWithLifecycle, fromandroidx.lifecycle:lifecycle-runtime-compose, collects while the screen's lifecycle is at least started and stops while the app is in the background. The Compose runtime'scollectAsStatekeeps collecting there, which for the library means a live read's refresh keeps trying, and a status keeps being mapped, for a screen nobody sees.SharingStarted.WhileSubscribed(5_000)keeps the upstream flows for five seconds after the last collector goes, so a rotation does not end the live read and start it again. A read started again after every collector has gone fetches again (Reactive reads); the five seconds are what keep a rotation from costing a request.- The initial value is what the screen draws before anything is known, here "nothing yet,
loading". A
StateFlowalways has a value, so the screen never meets anullit did not choose. - A screen composable takes values, not flows.
PhotosScreenis given its state, so the sample's tests render each designed state without a client, and a preview can (TasksScreenTest.kt).
Collect in a composable¶
A small, self-contained read can live in the composable that shows it. This listing is compiled by the sample's tests rather than taken from one of its screens:
From the samples: ComposeListings.kt
/** The open or the done todos, read live for as long as this is on screen. */
@Composable
fun TodoList(supabase: SupabaseClient, done: Boolean) {
// One flow per query, kept across recompositions: another `done` starts another read, and
// the read ends when the composable leaves the screen.
val snapshots =
remember(supabase, done) {
supabase.offline.from("todos").selectAsFlow {
filter { eq("done", done) }
order("title", Order.ASCENDING)
}
}
// Collected while the screen is started, not in the background; null until the first value.
val snapshot = snapshots.collectAsStateWithLifecycle(initialValue = null).value
val result = snapshot?.result
if (snapshot == null || result == null) {
Text("Loading…") // nothing known yet: nothing cached, and no queued write to show
} else {
val todos = remember(result) { result.decodeList<Todo>() }
LazyColumn {
if (snapshot.isFromCache) item { Text("Showing this device's copy") }
items(todos, key = { it.id }) { todo -> Text(todo.title) }
}
}
}
remember(inputs) { selectAsFlow { … } }builds the flow once per set of inputs. Built in the body withoutremember, each recomposition would make a new flow, andcollectAsStateWithLifecyclewould end the collection and start a new one each time.initialValue = nullstands for "no value yet", which means the same as an emission whoseresultisnull: nothing cached, and no queued write the query admits. The two together are the loading state; an empty body is an empty list (What each emission says).isFromCache,refreshandpendingRowKeyscome in the same emission as the rows, so a "from this device" caption or a per-row pending mark never trails the rows it describes.- Decode once per emission, under
remember(result), not on every recomposition.
A one-shot suspend read belongs in produceState, keyed on whatever should start it again. The
sample reads a photo's file this way, again when the server comes back in reach and once its
upload is done:
From the samples: PhotoThumbnail.kt
// Read again once the upload is done: the device's own copy then gives way to the cache's.
val isPending = photo.pendingUpload != null
val file by
produceState<PhotoFileState>(PhotoFileState.Loading, photo.id, canReachServer, isPending) {
value = loadFile(photo)
}
The status as one value¶
syncStatus is a StateFlow whose value changes as a whole, with its counts read together
(The status), so a composable can draw it directly:
/** One line about syncing, straight from the plugin's status. */
@Composable
fun SyncLine(supabase: SupabaseClient) {
// A StateFlow always has a value, so nothing is drawn before the first one.
val status by supabase.offline.syncStatus.collectAsStateWithLifecycle()
val waiting = status.pendingMutationCount
val offline =
status.network == NetworkStatus.OFFLINE || status.network == NetworkStatus.DISABLED
Text(
when {
offline -> "Offline: $waiting changes are saved on this device"
status.isSyncing -> "Sending $waiting changes"
waiting > 0 -> "$waiting changes waiting"
else -> "Up to date"
}
)
}
The sample keeps the status in a type of its own and keeps the flow a StateFlow, by giving
stateIn the current value to start from:
From the samples: OfflineTaskRepository.kt · SyncHeadline.kt · TasksViewModel.kt
/** The plugin's status in the app's words, still a StateFlow: the first value is read now. */
override val syncStatus: StateFlow<AppSyncStatus> =
offline.syncStatus
.map { it.toAppSyncStatus() }
.stateIn(scope, SharingStarted.Eagerly, offline.syncStatus.value.toAppSyncStatus())
Then one function decides what the Tasks screen says, from the status and the list's own refresh
together, in the order that matters most; the Tasks ViewModel combines the two flows into it:
/** The headline for [status] and the task list's [refresh], in order of what matters most. */
fun syncHeadlineOf(status: AppSyncStatus, refresh: TaskRefreshState): SyncHeadline {
val pending = status.pendingMutationCount
return when {
status.network == AppNetworkStatus.Offline || status.network == AppNetworkStatus.Disabled ->
SyncHeadline.Offline(pending)
status.isSyncing && pending > 0 -> SyncHeadline.Syncing(pending)
refresh is TaskRefreshState.Stalled -> SyncHeadline.ShowingSavedData
pending > 0 -> SyncHeadline.Waiting(pending)
else -> SyncHeadline.UpToDate
}
}
/** The subtitle and the banner's one value, from the status and the list's refresh together. */
private val headlines: Flow<SyncHeadline> =
combine(repository.syncStatus, allTasks.map { it.refresh }, ::syncHeadlineOf)
.withSyncedMoment()
withSyncedMoment holds "All changes synced" for two and a half seconds after a sync empties the
queue, then lets the banner go. The banner composable takes a SyncHeadline and draws it, with
nothing of the library in it. The mapping from the library's SyncStatus to the app's type is on
Replaying the queue, and what each refresh state should make a
screen say on Refresh lifecycle.
Compose State and AndroidX AppState¶
The library's values are flows, not Compose State, so that every value is seen, in order: a
status, and a read's snapshots, are values an app reads one after another, and the Swift package
wraps the same flows for SwiftUI. A composable turns a flow into State with
collectAsStateWithLifecycle, as above; code outside a composable that wants State derives it
with a headless composition, as Cash App's Molecule and AndroidX's transform do, collecting the
plugin's flows inside it as it would any other.
The AndroidX AppState
libraries, androidx.appstate:statestore and androidx.appstate:transform, are at their first
alpha as of October 2026. They need API 24 and bring the Compose runtime to every module that
depends on them, so the library does not depend on them and the samples do not use them. An app
that adopts them keeps using the plugin as this page shows: the flows are the surface, and what
derives State from them is the app's choice. Google's own guide to
choosing a state API
says the same: a Flow where every value must be processed in order.
Compose Multiplatform¶
The library's API is the same in commonMain, so a Compose Multiplatform app collects the same
flows with the same code on Android and iOS, and lifecycle-runtime-compose is multiplatform too.
The samples are an Android app in Compose and an iOS app in SwiftUI
(iOS apps in Swift).
Next steps¶
- Reactive reads: what each emission of a live read says.
- Replaying the queue: the status, and when the library sends by itself.
- Testing your app: the in-memory client, and the factories for test doubles.