DEV Community

Cover image for Below the Waterline: Running Compose State Where There Is No Screen
Android Expert
Android Expert

Posted on Originally published at Medium

Below the Waterline: Running Compose State Where There Is No Screen

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 SharedPreferences file, and a SavedStateHandle that 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 @Composable block without any UI and publishes its result as a State<R>. Whenever a State read 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.

state-dataflow

Two properties of the Compose runtime make this attractive outside the UI:

  1. 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.
  2. Atomic multi-field writes. Wrap several writes in Snapshot.withMutableSnapshot { ... } and observers see all of them or none. With StateFlow you 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.

artifact-map

// 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")
}
Enter fullscreen mode Exit fullscreen mode

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 transform or listener needs the Compose compiler plugin and compose = true. That includes a "pure Kotlin" domain module you thought was UI-free. A module that only reads and writes State<T> or StateStore does 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.


state-lifetime-ladder

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
}
Enter fullscreen mode Exit fullscreen mode

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.

transform-loop

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,
        )
    }
}
Enter fullscreen mode Exit fullscreen mode

What each decision buys you:

  • The early return@transform is dynamic tracking at work. While a delivery is active, the block never calls collectAsState, so the collector leaves composition and its collection is cancelled, which should stop the upstream query. No flatMapLatest, 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 your Flow handles cancellation.
  • remember(zone) keeps one query alive per zone, no matter how many other inputs (priority toggle, battery, delivery status) trigger recomposition.
  • previous is the last published output, which lets the board keep its last good content instead of flashing back to Loading.
  • ZoneOrders is a defense I will explain next.
  • Constructor-injected scope and dispatcher are what make this testable (section 10). ViewModel(scope) relies on the ViewModel(viewModelScope: CoroutineScope) constructor, which current lifecycle-viewmodel sources 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 plain ViewModel(), take the scope from a ViewModelProvider.Factory or your DI graph, and pass that to transform.

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
    }
}
Enter fullscreen mode Exit fullscreen mode

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
    }
}
Enter fullscreen mode Exit fullscreen mode

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() }
    }
}
Enter fullscreen mode Exit fullscreen mode

syncToDataStore suspends indefinitely, restoring saved values and then persisting future changes, which is why it gets its own launch.

persistence-path

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:

  1. Design defaults to be inert, so that observing Idle at startup can trigger nothing destructive.
  2. Run side effects on transitions into meaningful states, never on the mere presence of a default.
  3. 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.

pick-your-tool

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.

test-choreography

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")
    }
}
Enter fullscreen mode Exit fullscreen mode

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.

failure-mode-map

Three more that do not fit a card:

  • Compose-less module build breaks. A non-UI module that suddenly fails because someone added transform to it is missing the compiler plugin or compose = true. Fix the build once, in a convention plugin, rather than module by module.
  • SavedStateHandle.saved versus.saveable. The docs warn that bare saved hands back a plain property, not Compose State, so tracked blocks will not react to it. Use saveable { mutableStateOf(...) } for state a transform should watch.
  • Alpha churn. Hide transform, listener, and StateStore behind small interfaces owned by your code (PowerMonitor and DeliveryDesk above are the pattern). When the API shifts, you change one file per concept.

12. Rolling it out without a rewrite

  1. Start with one live-state singleton. Convert a callback-fed MutableStateFlow (battery, connectivity, a Bluetooth link) to a read-only State<T>. The change is small and consumers can keep reading it where they are.
  2. Replace one combine with one transform. Choose the ViewModel with the most upstream sources. Confirm the early-return and remember(key) patterns do what you expect by looking at database query counts in a profiler.
  3. 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

🔐 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.


Originally published on Medium.

Top comments (0)