Go back

Jetpack AppState: Compose State Beyond the UI

Android

Use AppState Transform to derive state outside the UI and StateStore to share it across app components, with Kotlin examples and practical limits.

In Compose, UI code reads snapshot state and describes what the screen should show. When that state changes, Compose re-runs only the parts that read it. Outside the UI, though, most Android architectures switch back to Flow pipelines—chaining operators in ViewModels and repositories, then converting streams back into Compose State at the screen boundary.

Jetpack AppState brings Compose’s snapshot model to the rest of the app, including ViewModels, repositories, and services. Its first alpha arrived on October 28, 2026, and at the time of writing both Transform and StateStore are on 1.0.0-alpha02.

AppState splits into two independent libraries: Transform derives state from other inputs, and StateStore holds shared state behind typed keys. You can adopt either one on its own.

If you have used Cash App’s Molecule, Transform will feel familiar: it runs a headless @Composable block outside the UI, except it produces a Compose State<T> directly rather than emitting a Flow<T>. That is the part worth looking at first, because combining uneven or conditional data sources is where Flow chains tend to get hard to read.

Deriving a product list

Imagine a product catalog with a search field, a category filter, and a sort option. The visible list depends on those three user inputs plus the products coming from a repository.

With Flow, we usually wire those inputs together with combine. That works fine for two or three streams, though typed combine overloads stop at five inputs before falling back to an untyped Array<Any?>, and conditional sources force nested flatMapLatest chains.

Transform lets us write that calculation as ordinary Kotlin inside a composable block. The block runs without drawing UI—defaulting to Dispatchers.Default—and returns a State<T>. Reading any Compose state inside it registers a snapshot dependency, and a repository Flow can join the calculation through collectAsState.

Because transform takes a @Composable lambda, any module that calls it needs the Compose compiler plugin (org.jetbrains.kotlin.plugin.compose), and Android modules need buildFeatures.compose = true—even if the module renders no UI. Check the AppState setup guide before adding it to a domain or data module you intended to keep free of Compose:

plugins {
// Required in any module that calls @Composable APIs like transform()
id("org.jetbrains.kotlin.plugin.compose")
}
dependencies {
implementation("androidx.appstate.transform:transform:1.0.0-alpha02")
}

Here is a small catalog based on the pattern in Google’s transformation examples:

import androidx.appstate.transform.transform
import androidx.compose.runtime.State
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.flow.Flow
data class Product(
val name: String,
val category: String,
val priceCents: Long,
)
enum class SortOrder { NAME, PRICE }
interface ProductRepository {
val products: Flow<List<Product>>
}
class CatalogViewModel(
private val repository: ProductRepository,
) : ViewModel() {
// Snapshot-backed inputs; mutations trigger recomposition inside transform()
var query by mutableStateOf("")
private set
var selectedCategory by mutableStateOf<String?>(null)
private set
var sortOrder by mutableStateOf(SortOrder.NAME)
private set
// Runs off the main thread (Dispatchers.Default by default) until viewModelScope cancels
val visibleProducts: State<List<Product>> = transform(
initialValue = emptyList(),
scope = viewModelScope,
) {
// Bridges the cold/hot repository Flow into Compose snapshot state
val products by repository.products.collectAsState(initial = emptyList())
// Reading these properties registers them as dependencies of this block
val search = query.trim()
val category = selectedCategory
val order = sortOrder
val filtered = products.filter { product ->
(category == null || product.category == category) &&
product.name.contains(search, ignoreCase = true)
}
when (order) {
SortOrder.NAME -> filtered.sortedBy { it.name }
SortOrder.PRICE -> filtered.sortedBy { it.priceCents }
}
}
fun updateQuery(value: String) { query = value }
fun selectCategory(value: String?) { selectedCategory = value }
fun changeSortOrder(value: SortOrder) { sortOrder = value }
}

Because transform defaults to Dispatchers.Default, filtering and sorting happen off the main thread automatically without an explicit flowOn call. Whenever query, selectedCategory, sortOrder, or the repository Flow emits a new value, the runtime recomposes the block and updates visibleProducts.

For brevity, an empty list stands in for the initial loading state here; a production catalog would model loading, empty results, and errors explicitly (and transform’s lambda also receives the previous output value if you want to keep stale data visible while refreshing). The filter state here also lives only in memory—SavedStateHandle.saveable is an option when inputs need to survive process death, as shown in the guide to Compose state outside the UI.

When the inputs change with a condition

A four-input calculation is still manageable with Flow.combine. Where Transform really earns its keep is when upstream sources appear and disappear based on runtime conditions.

Suppose the repository exposes a separate Flow of discounted products. With Flow, switching sources mid-pipeline means introducing flatMapLatest before or inside combine. Inside transform, we add a discountedOnly flag backed by mutableStateOf(false) and write an ordinary if expression:

// Only the active branch stays in the composition; switching cancels the other Flow
val products = if (discountedOnly) {
repository.discountedProducts.collectAsState(initial = emptyList()).value
} else {
repository.products.collectAsState(initial = emptyList()).value
}

Only the active branch collects its Flow. Flipping discountedOnly leaves the old branch, cancels its collection, and starts collecting the new one. The condition stays right next to the data source it selects, as in the conditional examples in the Transform guide.

If a repository function creates a new Flow from an argument, wrap that call in remember(argument) so unrelated recompositions—such as typing in the search box—do not recreate and restart the upstream stream:

// Reuses the same Flow instance across query or sort changes until `category` changes
val categoryFlow = remember(category) {
repository.productsByCategory(category)
}
val products by categoryFlow.collectAsState(initial = emptyList())

Recomposition, lifetime, and conflation

Running Compose outside the UI brings the same runtime rules you already know from screens, plus one important lifecycle difference.

Side effects and cancellation. A transform block can recompose frequently, so keep pure calculations safe to repeat and move asynchronous work into effects like LaunchedEffect. When an effect’s key changes or its branch leaves the composition, its coroutine cancels automatically—handy for debouncing a search network call by delaying briefly before firing the request.

Background lifetime. Our catalog passes viewModelScope to transform, which means its composition stays active until the ViewModel is cleared. Putting the app in the background does not pause it. Watch out for a subtle interaction here: while a StateFlow built with SharingStarted.WhileSubscribed stops upstream work when the UI stops collecting on STOPPED, collectAsState inside a viewModelScope-backed transform stays subscribed in the background and keeps that upstream Flow alive. Scope the transform to match when the work should actually run.

Skipped intermediate values. Transform updates asynchronously and can conflate rapid changes, skipping intermediate states when inputs mutate faster than recomposition runs. Stick with a Flow pipeline whenever every emission must be processed in order, such as analytics or an event log. Conversely, if every input is already Compose state and the derivation is cheap and synchronous, derivedStateOf is simpler.

Sharing playback state with StateStore

Now consider an audio player. A full player screen, a mini-player in another activity, and a background playback service all need to observe the current track and position. StateStore gives them a shared, process-wide container where each StateStoreKey defines the type and default value of the state it holds.

Modules that only read and write StateStore keys do not need the Compose compiler plugin:

dependencies {
implementation("androidx.appstate.statestore:statestore:1.0.0-alpha02")
}

Keep the key private inside the component that owns playback so external callers can observe state without writing to the store directly:

import androidx.appstate.statestore.StateStore
import androidx.appstate.statestore.StateStoreKey
import androidx.compose.runtime.State
data class PlaybackState(
val trackId: String? = null,
val isPlaying: Boolean = false,
val positionMs: Long = 0L,
)
// Private key restricts direct StateStore mutations to this file
private data object PlaybackKey : StateStoreKey<PlaybackState>(
defaultValue = PlaybackState(),
)
class PlaybackManager(private val store: StateStore) {
// Exposed as read-only Compose State for UI and non-UI observers alike
val playback: State<PlaybackState> = store.getState(PlaybackKey)
internal fun onPlayerStateChanged(trackId: String?, isPlaying: Boolean) {
// updateState() performs an atomic read-modify-write so positionMs isn't overwritten
store.updateState(PlaybackKey) { current ->
current.copy(trackId = trackId, isPlaying = isPlaying)
}
}
internal fun onPositionDiscontinuity(positionMs: Long) {
store.updateState(PlaybackKey) { current ->
current.copy(positionMs = positionMs)
}
}
}

Create the StateStore once at the application or dependency injection root and inject PlaybackManager wherever playback state is needed—the same ownership pattern used in Google’s StateStore examples. Using updateState instead of overwriting the whole object with setState avoids clobbering fields when callbacks arrive independently.

Compose UI screens can read playbackManager.playback.value directly. Non-UI components like a Service—which do not have a UI composition—can observe the same State<PlaybackState> from a coroutine with snapshotFlow (or androidx.appstate.transform.listener if Compose is enabled in that module):

// Inside a Service or repository coroutine scope (no Compose compiler plugin needed)
serviceScope.launch {
snapshotFlow { playbackManager.playback.value }
.distinctUntilChangedBy { it.isPlaying }
.collect { state ->
updateNotification(state)
}
}

I would still pause and ask whether StateStore earns its place in your graph. A singleton manager holding a private val _playback = mutableStateOf(PlaybackState()) can already expose State<PlaybackState> with zero extra dependencies. StateStore becomes worthwhile when multiple features register independent typed keys in a shared store or opt into its DataStore persistence bridge.

Deciding what survives a restart

StateStore lives in memory within a single process; a service configured with android:process in another process will not share the same instance.

For state that should survive process death or app restarts, the optional statestore-datastore artifact (androidx.appstate.statestore:statestore-datastore:1.0.0-alpha02) bridges selected keys to Jetpack DataStore using kotlinx.serialization:

// 1. Mark serializable keys with PersistToDataStore.
// Keep rapidly ticking values (like live playback position) out of persisted keys,
// since every update triggers a disk write.
@Serializable
data class PlaybackPrefs(val lastTrackId: String? = null)
@Serializable
private data object PlaybackPrefsKey : StateStoreKey<PlaybackPrefs>(
defaultValue = PlaybackPrefs(),
), PersistToDataStore
// 2. Connect the shared store once; syncToDataStore suspends indefinitely to sync changes
applicationScope.launch {
stateStore.syncToDataStore(
path = context.dataStoreFile("appstate.json").absolutePath,
coroutineScope = this,
)
}

Keep in mind that restoration is asynchronous: early readers will observe defaultValue until DataStore finishes loading from disk. For an audio player, restoring the last track also requires a product decision about when to cue the player versus auto-resuming audio—logic that still belongs in PlaybackManager, not the store.

Testing asynchronous transforms

Because transform runs asynchronously on a background dispatcher, unit tests cannot mutate an input and immediately assert on .value on the next line. The transform-testing artifact (androidx.appstate.transform:transform-testing) provides transformTest, a testDispatcher, and advance() to drive snapshots and recomposition deterministically:

import androidx.appstate.transform.testing.transformTest
import androidx.appstate.transform.transform
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import kotlin.test.Test
import kotlin.test.assertEquals
class CatalogTransformTest {
@Test
fun filtersProductsWhenQueryChanges() = transformTest {
var query by mutableStateOf("")
val catalog = listOf("Keyboard", "Mouse", "Monitor")
// Bind transform() to backgroundScope and testDispatcher from TransformTestScope
val filtered = transform(
initialValue = emptyList(),
scope = backgroundScope,
dispatcher = testDispatcher,
) {
catalog.filter { it.contains(query, ignoreCase = true) }
}
// Flush initial composition
advance()
assertEquals(3, filtered.value.size)
// Mutate snapshot state, then call advance() so recomposition settles before asserting
query = "mo"
advance()
assertEquals(listOf("Mouse", "Monitor"), filtered.value)
}
}

If you wrap transform inside a ViewModel like CatalogViewModel, inject the CoroutineDispatcher (defaulting to Dispatchers.Default) so unit tests can pass testDispatcher in.

Where I would start

Since AppState is still on 1.0.0-alpha02, I would not rewrite working StateFlow pipelines across an app. Instead, pick one feature where a combine + flatMapLatest chain has grown genuinely hard to follow. Keep the repository interfaces untouched, rewrite just the state derivation with transform, and compare the two side by side—checking rapid input changes, cancellation, and background behavior.

What makes AppState promising is being able to read a reactive calculation from top to bottom as plain Kotlin, even when its inputs branch on a condition. Use that as the bar for adoption: keep it only where the new version makes the feature noticeably easier to read and test.