A senior engineer’s tour of Jetpack AppState (Transform and StateStore), told through the bugs that only show up after launch.
1. The trouble with state nobody is looking at
Picture Pedalpost, a bike-courier app (invented for this post). One fact, “the courier is standing at a customer’s door,” has to be true in four places at once: the dispatch screen, a foreground service, a home-screen widget, and a notification action. Add a battery-saver flag, a zone filter, and an order feed, and you have the usual Android state mess:
- A
combine(...)call that grows a sixth upstream and loses its typed overload. - A
MutableStateFlow<UiState>that wakes every collector when a single field changes, or a pile of separate flows that cannot be updated together. - A singleton, a
SharedPreferencesfile, and aSavedStateHandlethat each claim to own "the current delivery" and quietly disagree after process death. - Timers and notifications buried inside collectors, with cancellation rules nobody can recite.
Jetpack AppState attacks this from a direction that surprises people: it takes Compose’s snapshot state system, which has nothing inherently to do with pixels, and lets you use it in ViewModels, managers, repositories, and services. Reads are tracked automatically, writes can be committed atomically, and the whole thing is held in ordinary State<T> objects.
This post covers what you are adopting, how to wire it up, and where it hurts in production. The examples are mine and deliberately small, and the failure modes come from reasoning about how the runtime behaves (each one is labeled as documented behavior or as my inference).
2. What you are actually adopting
AppState is two libraries with different jobs:
-
Transform runs a
@Composableblock without any UI and publishes its result as aState<R>. Whenever aStateread inside the block changes, the block recomposes and the output updates. A companion function,listener, does the same for effect-only blocks that produce no value. -
StateStore is a type-safe, in-memory container of keyed state, exposed as
State<T>. An optional DataStore integration persists chosen keys so they survive process death.
Two properties of the Compose runtime make this attractive outside the UI:
- Dynamic dependency tracking. A state is a dependency only if the code path actually read it on the latest run. A branch that does not execute holds no subscriptions.
-
Atomic multi-field writes. Wrap several writes in
Snapshot.withMutableSnapshot { ... }and observers see all of them or none. WithStateFlowyou choose between one fat state object that invalidates every collector and many small flows that cannot change together.
3. Gradle: the cheap part that bites
The overview page splits the library into runtime artifacts, a testing artifact, and two compiler-side requirements. The map below shows which piece needs which.
// build.gradle.kts (module that owns Pedalpost's state logic)
plugins {
id("org.jetbrains.kotlin.plugin.compose")
id("org.jetbrains.kotlin.plugin.serialization") // only if you persist keys
}
android {
buildFeatures { compose = true } // required for transform / listener, even without UI
}
dependencies {
implementation("androidx.appstate.transform:transform:1.0.0-alpha01")
implementation("androidx.appstate.statestore:statestore:1.0.0-alpha01")
// optional: persistence
implementation("androidx.appstate.statestore:statestore-datastore:1.0.0-alpha01")
implementation("org.jetbrains.kotlinx:kotlinx-serialization-core:1.11.0")
testImplementation("androidx.appstate.transform:transform-testing:1.0.0-alpha01")
}
The versions above are the ones the overview page listed at the time of writing, with Kotlin 2.4.10 for the plugins. Two rules follow from the map:
-
Any module that calls
transformorlistenerneeds the Compose compiler plugin andcompose = true. That includes a "pure Kotlin" domain module you thought was UI-free. A module that only reads and writesState<T>orStateStoredoes not. -
The serialization plugin and core library are for persistence only. Skip them until a key implements
PersistToDataStore.
4. Decide lifetime before you decide API
Most state-management arguments are really lifetime arguments in disguise. Ask “how long must this survive?” first, and the API choice mostly falls out.
Mapped onto Pedalpost:
- Live: the battery-saver flag. Re-read from the system on every launch; persisting it would only store stale facts.
- Screen: the zone filter chips. They should survive rotation and system-initiated process death, but nobody wants last Tuesday’s filter.
- Session: the delivery in progress. It must survive process death and be visible to the activity, the service, and the widget.
- Permanent: payout history. It belongs in Room or DataStore, and you read it through flows.
The middle two tiers are where AppState adds the most. The first and last already had decent answers.
5. Live state: a battery monitor with no ceremony
A live fact is a mutableStateOf in an application-scoped object, exposed read-only.
interface PowerMonitor {
val isBatteryLow: State<Boolean>
}
class SystemPowerMonitor(context: Context) : PowerMonitor {
private val _isBatteryLow = mutableStateOf(false)
override val isBatteryLow: State<Boolean> = _isBatteryLow
init {
val receiver = object : BroadcastReceiver() {
override fun onReceive(c: Context, intent: Intent) {
_isBatteryLow.value = intent.looksLow()
}
}
// ACTION_BATTERY_CHANGED is sticky, so registering returns the current
// reading. Seed from it instead of waiting for the next change.
ContextCompat.registerReceiver(
context,
receiver,
IntentFilter(Intent.ACTION_BATTERY_CHANGED),
ContextCompat.RECEIVER_NOT_EXPORTED,
)?.let { _isBatteryLow.value = it.looksLow() }
}
}
private fun Intent.looksLow(): Boolean {
val level = getIntExtra(BatteryManager.EXTRA_LEVEL, -1)
val scale = getIntExtra(BatteryManager.EXTRA_SCALE, -1)
return level >= 0 && scale > 0 && level * 100 / scale <= 15
}
MutableState tolerates reads and writes from any thread, so the receiver can write directly. If a callback updates several states that must agree, wrap those writes in Snapshot.withMutableSnapshot.
6. Transform: the dispatch board
Before the code, one picture of what a transform does with each change.
Now the dispatch board. It shows open orders for a zone, respects a priority filter, and reacts to battery saver and to whatever the courier is doing right now.
sealed interface BoardState {
data object Loading : BoardState
data object Paused : BoardState
data class Ready(
val orders: List<OpenOrder>,
val zone: Zone,
val saverMode: Boolean,
) : BoardState
}
private data class ZoneOrders(val zone: Zone, val orders: List<OpenOrder>)
class DispatchBoardViewModel(
private val orders: OrderRepository,
private val power: PowerMonitor,
private val desk: DeliveryDesk,
scope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate),
dispatcher: CoroutineDispatcher = Dispatchers.Default,
) : ViewModel(scope) {
var zone by mutableStateOf(Zone.Downtown)
private set
var priorityOnly by mutableStateOf(false)
private set
fun applyFilters(zone: Zone, priorityOnly: Boolean) {
Snapshot.withMutableSnapshot { // observers never see half of this
this.zone = zone
this.priorityOnly = priorityOnly
}
}
val board: State<BoardState> = transform<BoardState>(
initialValue = BoardState.Loading,
scope = viewModelScope,
dispatcher = dispatcher,
) { previous ->
// A courier mid-delivery has no use for the board. Returning early means
// the database flow below is never reached, so nothing is collected.
if (desk.active.value !is ActiveDelivery.Idle) {
return@transform BoardState.Paused
}
// Re-key the query only when the zone changes, and stamp every emission
// with the zone that produced it.
val stamped by remember(zone) {
orders.observeOpen(zone).map { ZoneOrders(zone, it) }
}.collectAsState(initial = null)
val current = stamped ?: return@transform previous
val saver by power.isBatteryLow
BoardState.Ready(
orders = current.orders.filter { !priorityOnly || it.isPriority },
zone = current.zone,
saverMode = saver,
)
}
}
What each decision buys you:
-
The early
return@transformis dynamic tracking at work. While a delivery is active, the block never callscollectAsState, so the collector leaves composition and its collection is cancelled, which should stop the upstream query. NoflatMapLatest, no manual cancel. Treat this as expected behavior, not a guarantee: dropping the subscription is standard Compose semantics, but I have not measured it on the alpha artifacts. Confirm the query really stops by counting subscriptions on your own repository (or checking a profiler), because the result depends on how yourFlowhandles cancellation. -
remember(zone)keeps one query alive per zone, no matter how many other inputs (priority toggle, battery, delivery status) trigger recomposition. -
previousis the last published output, which lets the board keep its last good content instead of flashing back toLoading. -
ZoneOrdersis a defense I will explain next. -
Constructor-injected scope and dispatcher are what make this testable (section 10).
ViewModel(scope)relies on theViewModel(viewModelScope: CoroutineScope)constructor, which currentlifecycle-viewmodelsources declare publicly and which the Transform docs use in their own testing sample. If you are on an older lifecycle release without it, keep a plainViewModel(), take the scope from aViewModelProvider.Factoryor your DI graph, and pass that totransform.
Failure mode: stale-while-revalidate is the default
collectAsState is built on produceState, and its backing state is remembered without the flow as a key. When remember(zone) hands it a new flow, the old value stays visible until the new flow emits (I checked this against the Compose runtime source: produceState holds its result in a remember { mutableStateOf(initial) } with no key, and only the effect that fills it is re-keyed). If the block then pairs zone (already new) with orders (still old), the board briefly shows Downtown's orders under an Uptown heading. Stamping each emission with the zone that produced it makes the pairing true by construction.
Failure mode: phantom restarts
Delete remember(zone) and the repository returns a brand-new Flow on every recomposition. collectAsState treats it as a new input and restarts collection. At best you re-run the query whenever any tracked input changes. At worst, if your item type lacks structural equality, every restart emits a "different" list, which changes state, which recomposes, which builds another flow. That is a self-sustaining loop (my inference, but an easy one to trigger with a class that is not a data class). Key every flow you build inside a transform.
Failure mode: the background burn
The docs spell this one out: a transform scoped to viewModelScope runs for as long as that scope lives, including while the UI is in the background. The @Composable overload of transform follows the composition that calls it. Use the former for facts other components need, and the latter for derivations only one screen needs.
7. Listener: a doorstep watchdog
If a courier arrives and nobody answers for five minutes, Pedalpost should ping support exactly once. listener is built for this: per the docs, a suspend function that composes a block until its coroutine is cancelled, with no output state. It is an alpha API and I have not verified its exact cancellation semantics in the shipped artifact, so write a test that cancels the calling coroutine and asserts the effect stops, on the version you adopt.
class DoorstepWatchdog(
private val desk: DeliveryDesk,
private val support: SupportDesk,
private val clock: () -> Long = System::currentTimeMillis,
) {
suspend fun run(dispatcher: CoroutineDispatcher = Dispatchers.Default) =
listener(dispatcher) {
val current = desk.active.value
if (current is ActiveDelivery.AtDoorstep && !current.escalated) {
LaunchedEffect(current.orderId) {
val wait = (current.arrivedAtMillis + GRACE_MILLIS - clock())
.coerceAtLeast(0L)
delay(wait)
support.reportUnreachable(current.orderId) // idempotent per order
desk.markEscalated(current.orderId) // last statement, on purpose
}
}
}
private companion object {
const val GRACE_MILLIS = 5 * 60_000L
}
}
Failure mode: the self-cancelling effect
If you swap the last two lines, markEscalated flips the very condition that keeps the effect in composition. Once the next recomposition runs, the effect leaves, its coroutine is cancelled, and the network call dies at its first suspension point. Mutate the gating state last, after the work is done. (This follows from standard Compose effect semantics. It is not specific to AppState, but non-UI code makes it easy to forget.)
Failure mode: replay after process death
Persisted state plus timers means a restart can re-fire work that already happened. Here, the escalated flag lives inside the persisted state, so a restored AtDoorstep(escalated = true) never enters the if. The ordering above gives at-least-once delivery, which is why reportUnreachable must be idempotent on the server, keyed by orderId. If a crash lands between the two lines, you will ping twice rather than never.
8. StateStore: the delivery desk
The session tier is a sealed hierarchy, a key, and a manager that owns both.
@Serializable
sealed interface ActiveDelivery {
@Serializable data object Idle : ActiveDelivery
@Serializable
data class EnRoute(val orderId: String, val pickedUpAtMillis: Long) : ActiveDelivery
@Serializable
data class AtDoorstep(
val orderId: String,
val arrivedAtMillis: Long,
val escalated: Boolean = false,
) : ActiveDelivery
}
@Serializable
private data object ActiveDeliveryKey :
StateStoreKey<ActiveDelivery>(defaultValue = ActiveDelivery.Idle),
PersistToDataStore
class DeliveryDesk(
private val store: StateStore,
private val clock: () -> Long = System::currentTimeMillis,
) {
val active: State<ActiveDelivery> = store.getState(ActiveDeliveryKey)
fun pickUp(orderId: String) =
store.setState(ActiveDeliveryKey, ActiveDelivery.EnRoute(orderId, clock()))
fun arrive() = store.updateState(ActiveDeliveryKey) { now ->
if (now is ActiveDelivery.EnRoute) {
ActiveDelivery.AtDoorstep(now.orderId, arrivedAtMillis = clock())
} else now
}
fun handOff() = store.setState(ActiveDeliveryKey, ActiveDelivery.Idle)
internal fun markEscalated(orderId: String) = store.updateState(ActiveDeliveryKey) { now ->
if (now is ActiveDelivery.AtDoorstep && now.orderId == orderId) {
now.copy(escalated = true)
} else now
}
}
Keeping the key private is the point: the screen and the service cannot invent their own writes, and every transition goes through a function with a name and rules. Application wiring is a single call, made once, on a scope that lives as long as the process:
import java.io.File
class PedalpostApp : Application() {
// Deliberately never cancelled: it must live as long as the process. Launch only
// long-lived collectors here, and keep per-feature work in narrower scopes.
private val appScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
val stateStore = StateStore()
val desk = DeliveryDesk(stateStore)
override fun onCreate() {
super.onCreate()
appScope.launch {
stateStore.syncToDataStore(
// A plain File avoids depending on a DataStore extension import. The docs'
// own sample uses dataStoreFile("..."), which also works if it resolves
// in your DataStore version.
path = File(filesDir, "pedalpost-state.json").absolutePath,
coroutineScope = appScope,
)
}
appScope.launch { DoorstepWatchdog(desk, SupportDesk.live()).run() }
}
}
syncToDataStore suspends indefinitely, restoring saved values and then persisting future changes, which is why it gets its own launch.
Failure mode: the default mistaken for a fact
The documented cold-start behavior is that getState first returns defaultValue from memory and updates once DataStore finishes reading. Because this is the highest-stakes assumption in the post, write the cold-start test (default first, restored value second) against your exact version before shipping. The pages I read describe no "restore finished" signal. (Check the current release notes and issue tracker before relying on that.) The danger is code that acts on the default. A "no active delivery, so clear the notification channel" cleanup would run in that window and destroy state that was about to be restored. Mitigations, in order of preference:
- Design defaults to be inert, so that observing
Idleat startup can trigger nothing destructive. - Run side effects on transitions into meaningful states, never on the mere presence of a default.
- Before any irreversible action, reconcile with the source of truth (the backend) instead of trusting local state alone.
I would not invent a “Restoring” sentinel as the default. On a fresh install nothing is ever restored, so the sentinel would never be replaced.
Failure mode: disk churn
Every PersistToDataStore key in a store serializes into one file on each change (documented). Persist anchors such as pickedUpAtMillis rather than elapsed-seconds counters, keep raw text-field input in plain in-memory state and persist it once it settles, and leave large datasets to Room.
Failure mode: process walls
A StateStore lives in memory in the process that created it. A service declared with its own android:process gets its own empty store and will never see your activity's writes. Use IPC across processes, or keep the components that share state in one.
9. Flow, derivedStateOf, or transform?
The three overlap enough that teams pick by habit. Two questions settle most cases.
The first question matters because a transform models what is true now, not what happened. Compose keeps only the latest value if several writes land before recomposition, and it skips publishing when the new output equals the old one. That is wonderful for a screen and wrong for a ledger. Running totals, undo history, audit logs, one-shot commands (“sound the horn” twice means twice), and streams that need backpressure all stay in Flow. You can fold a Flow into a transform afterward when you need to combine its result with other state.
10. Testing without flaky sleeps
transform-testing gives you transformTest, which wraps runTest and adds explicit control over recomposition. The rhythm is always the same.
class DoorstepWatchdogTest {
@Test
fun escalatesOnceAfterGracePeriod() = transformTest {
val clock = { testCoroutineScheduler.currentTime }
val desk = DeliveryDesk(StateStore(), clock)
val support = FakeSupportDesk()
backgroundScope.launch { DoorstepWatchdog(desk, support, clock).run(testDispatcher) }
desk.pickUp("A-17")
desk.arrive()
advance() // effect starts
advanceTimeBy(4.minutes); advance()
assertThat(support.reports).isEmpty()
advanceTimeBy(1.minutes); advance()
assertThat(support.reports).containsExactly("A-17")
advanceTimeBy(30.minutes); advance()
assertThat(support.reports).hasSize(1) // no repeat pings
}
@Test
fun restartDoesNotRepeatAnEscalation() = transformTest {
val clock = { testCoroutineScheduler.currentTime }
val store = StateStore() // survives the "crash" below
val desk = DeliveryDesk(store, clock)
val support = FakeSupportDesk()
val firstRun = backgroundScope.launch {
DoorstepWatchdog(desk, support, clock).run(testDispatcher)
}
desk.pickUp("A-17"); desk.arrive()
advance(); advanceTimeBy(5.minutes); advance()
assertThat(support.reports).containsExactly("A-17")
firstRun.cancel() // pretend the process died
backgroundScope.launch { DoorstepWatchdog(desk, support, clock).run(testDispatcher) }
advance(); advanceTimeBy(10.minutes); advance()
assertThat(support.reports).containsExactly("A-17")
}
}
These are written against the documented transformTest surface and have not been compiled against your project. Treat the exact number of advance() calls as a starting point and adjust it to what your blocks do. The same applies to the dispatcher habit: any class that builds a transform should accept a scope and a dispatcher through its constructor, as DispatchBoardViewModel does. Without that, the block runs on a real Dispatchers.Default thread and your assertions race it.
11. Failure-mode field map
Everything above, plus the pitfalls from the docs, on one page you can pin next to your review checklist.
Three more that do not fit a card:
-
Compose-less module build breaks. A non-UI module that suddenly fails because someone added
transformto it is missing the compiler plugin orcompose = true. Fix the build once, in a convention plugin, rather than module by module. -
SavedStateHandle.savedversus.saveable. The docs warn that baresavedhands back a plain property, not ComposeState, so tracked blocks will not react to it. Usesaveable { mutableStateOf(...) }for state a transform should watch. -
Alpha churn. Hide
transform,listener, andStateStorebehind small interfaces owned by your code (PowerMonitorandDeliveryDeskabove are the pattern). When the API shifts, you change one file per concept.
12. Rolling it out without a rewrite
-
Start with one live-state singleton. Convert a callback-fed
MutableStateFlow(battery, connectivity, a Bluetooth link) to a read-onlyState<T>. The change is small and consumers can keep reading it where they are. -
Replace one
combinewith onetransform. Choose the ViewModel with the most upstream sources. Confirm the early-return andremember(key)patterns do what you expect by looking at database query counts in a profiler. -
Move one cross-component session into a
StateStore. Add persistence only after in-memory sharing is solid, and write the cold-start test (default first, restored value second) before shipping it.
Keep Flow for event streams and ledgers. AppState is the better tool for "what is true right now," and that is a larger share of an app than most architectures admit.
Sources and further reading
- AppState overview (artifacts, setup, status)
- Use Compose State outside the UI
- Transform
- StateStore
🔐 Go Beyond Using Android — Master How It’s Actually Secured
If you’re building, testing, or breaking Android apps, understanding the security model beneath the SDK separates developers who follow best practices from those who truly understand why those practices exist. I highly recommend “Mastering Android Security: From Fundamentals to Advanced Practice.” It’s a comprehensive, architecture-first journey through app sandboxing, cryptography, network defense, reverse engineering, and exploitation — spanning everything from AndroidManifest.xml internals to TrustZone and the Verified Boot chain, with hands-on labs at every step.
- E-book: Available on Google Play
- Kindle Edition: Available on Amazon
- Topmate: Available on Topmate
- Also available in Paperback & Hardcover
Originally published on Medium.








Top comments (0)