| name | collecting-flows-safely |
| description | Use this skill to migrate Compose UI from `collectAsState()` to `collectAsStateWithLifecycle()`, hoist `Flow<T>` parameters out of composables, and apply `.conflate()` / `.distinctUntilChanged()` / `snapshotFlow` so background CPU and battery stop draining and chatty flows stop invalidating the UI per emission. Covers ViewModel `StateFlow`/`SharedFlow` consumers, sensor and location streams, and the "Flow as composable parameter" antipattern. Trigger when the user mentions `collectAsState`, `collectAsStateWithLifecycle`, lifecycle-aware flow collection, `Lifecycle.State.STARTED`, background battery drain from a Compose screen, `snapshotFlow`, `Flow` parameter on a composable, conflate, or distinctUntilChanged. |
| license | Apache-2.0. See LICENSE for complete terms. |
| metadata | {"author":"Jaewoong Eum (skydoves)","keywords":["jetpack-compose","performance","collectasstatewithlifecycle","flow","lifecycle","battery","snapshotflow","side-effects"]} |
Collecting Flows Safely — keep upstream work tied to the UI lifecycle
collectAsState() keeps collecting whenever the composable is in composition, including while the app is backgrounded — that wastes CPU and battery. collectAsStateWithLifecycle() ties collection to a Lifecycle.State (default STARTED) so backgrounding pauses upstream work. For chatty flows, pair the consumer with .conflate() and .distinctUntilChanged() so Compose only sees meaningful changes. Skydoves hot take #4: a Flow<T> parameter on a composable is unstable and blocks skipping for the whole composable — collect at the caller and pass the resolved value.
When to use this skill
- A
ViewModel or Repository exposes StateFlow<T>, SharedFlow<T>, or a cold Flow<T> to a Compose screen.
- The user reports background battery drain, "the screen keeps working when the app is in the recents tray", or wakelock noise on logcat.
- A composable consumes sensor data, location, GPS, websocket, or animation frames coming from outside Compose.
- A composable's signature is
fun MyScreen(state: Flow<State>) (Flow-as-parameter antipattern).
- The user asks about
collectAsState vs collectAsStateWithLifecycle, or about Lifecycle.State.STARTED / RESUMED semantics.
- The user wants Compose state to drive a non-Compose subscriber (analytics on visible item index, etc.) — that is the State→Flow direction served by
snapshotFlow.
When NOT to use this skill
- The flow is constructed and consumed entirely inside one composable's
remember { ... } block — that is composition-internal state, prefer mutableStateOf directly. See ../using-efficient-effects/SKILL.md for choosing the right effect API.
- The data source is already
State<T> (e.g. mutableStateOf, Animatable.asState()) — do not wrap it in a flow just to call collectAsStateWithLifecycle().
- A truly always-on background listener that must run while the Activity is
STOPPED. Move that work to a Service / WorkManager / repeatOnLifecycle in the Activity, not into Compose.
Prerequisites
- Compose UI 1.4+ (any modern release).
- Add
androidx.lifecycle:lifecycle-runtime-compose (2.6+; the artifact that exposes collectAsStateWithLifecycle). Maven coordinates: androidx.lifecycle:lifecycle-runtime-compose:<version>.
- Kotlin coroutines basics (
StateFlow, SharedFlow, conflate, distinctUntilChanged).
- Read
../../recomposition/deferring-state-reads/SKILL.md if the high-frequency emissions are driving animation values — phase deferral may be a better fix than .conflate().
Workflow
- Audit every
collectAsState() call. Search the module for \.collectAsState\(. Replace each call with collectAsStateWithLifecycle() unless the producing flow is created inside the same composable scope.
- Provide an
initialValue when the flow is not a StateFlow (cold Flow<T> or SharedFlow<T>). For StateFlow<T>, the overload reads .value — no initial value needed.
- Pick the right
minActiveState. Default STARTED matches the framework's repeatOnLifecycle default. Use RESUMED for widgets that should only collect while the Activity owns input focus (e.g. always-visible foreground HUD with an aggressive sensor source). Do not use CREATED — that defeats the purpose.
- For high-frequency producers (>1 emission per ~100 ms), add
.conflate() upstream of collectAsStateWithLifecycle() so the consumer keeps only the latest value across a frame. If consecutive emissions can be value-equal, also add .distinctUntilChanged() — and ensure the emitted type has a correct equals().
- Hoist
Flow<T> parameters out of composables. Replace fun Foo(prices: Flow<Price>) with fun Foo(price: Price). Collect at the caller. If the producer must stay private to the parent, expose a () -> Price lambda provider rather than the raw Flow.
- For State → Flow direction, use
snapshotFlow { ... } inside a LaunchedEffect. That is the supported bridge from Compose's snapshot system to coroutine flows; combine with .distinctUntilChanged() to avoid spurious emissions.
- Verify with
@TraceRecomposition (see ../../measurement/tracing-recompositions-at-runtime/SKILL.md) and a logcat sanity check with the app backgrounded — upstream emissions should stop.
Patterns
Pattern: replace collectAsState with collectAsStateWithLifecycle
import androidx.compose.runtime.collectAsState
@Composable
fun HomeScreen(viewModel: HomeViewModel) {
val state by viewModel.uiState.collectAsState()
HomeContent(state)
}
import androidx.lifecycle.compose.collectAsStateWithLifecycle
@Composable
fun HomeScreen(viewModel: HomeViewModel) {
val state by viewModel.uiState.collectAsStateWithLifecycle()
HomeContent(state)
}
Pattern: custom minActiveState for an always-visible widget
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.compose.collectAsStateWithLifecycle
@Composable
fun TopBar(viewModel: HomeViewModel) {
val state by viewModel.uiState.collectAsStateWithLifecycle(
minActiveState = Lifecycle.State.RESUMED,
)
TopBarContent(state)
}
Pattern: do not pass Flow<T> as a composable parameter (skydoves hot take #4)
@Composable
fun PriceTicker(prices: Flow<Price>) {
val price by prices.collectAsState(initial = Price.ZERO)
Text(price.formatted)
}
@Composable
fun PriceTicker(price: Price) {
Text(price.formatted)
}
@Composable
fun TickerScreen(viewModel: TickerViewModel) {
val price by viewModel.price.collectAsStateWithLifecycle()
PriceTicker(price)
}
Pattern: high-frequency flow needs .conflate() + .distinctUntilChanged()
@Composable
fun TiltIndicator(repository: SensorRepository) {
val tilt by repository.tilt.collectAsStateWithLifecycle(initialValue = 0f)
TiltUi(tilt)
}
@Composable
fun TiltIndicator(repository: SensorRepository) {
val flow = remember(repository) {
repository.tilt.conflate().distinctUntilChanged()
}
val tilt by flow.collectAsStateWithLifecycle(initialValue = 0f)
TiltUi(tilt)
}
Pattern: State → Flow with snapshotFlow
@Composable
fun FeedAnalytics(listState: LazyListState, analytics: Analytics) {
LaunchedEffect(listState, analytics) {
snapshotFlow { listState.firstVisibleItemIndex }
.distinctUntilChanged()
.collect { index -> analytics.logFirstVisibleIndex(index) }
}
}
Pattern: prefer snapshotFlow over derivedStateOf for fire-and-forget reactions
val isAtTop by remember { derivedStateOf { listState.firstVisibleItemIndex == 0 } }
LaunchedEffect(isAtTop) { if (isAtTop) reportTop() }
LaunchedEffect(listState) {
snapshotFlow { listState.firstVisibleItemIndex == 0 }
.distinctUntilChanged()
.filter { it }
.collect { reportTop() }
}
Mandatory rules
- MUST call
collectAsStateWithLifecycle() (from androidx.lifecycle:lifecycle-runtime-compose) for any Flow that originates outside the composition (ViewModel, repository, sensor, network, websocket, location).
- MUST NOT pass
Flow<T> as a composable parameter. Collect at the caller, pass the resolved T (or a () -> T lambda provider for very hot producers). Flow is unstable and blocks skipping for the entire composable.
- MUST add
.conflate() and/or .distinctUntilChanged() upstream of collectAsStateWithLifecycle() for any flow emitting more often than ~once per 100 ms. Ensure equals() is correct on the emitted type when using distinctUntilChanged.
- MUST wrap the operator chain in
remember(key) when applying .conflate() / .distinctUntilChanged() inline, so a new chain is not created on every recomposition.
- MUST NOT use
Lifecycle.State.CREATED for minActiveState — it leaves collection running while the activity is invisible, defeating the migration.
- MUST NOT rewrap an existing
State<T> into a flow just to call collectAsStateWithLifecycle(). Keep the State direct.
- PREFERRED:
snapshotFlow { ... } over derivedStateOf { ... } when the only consumer is a fire-and-forget effect (analytics, logging, side-channel emit) instead of UI.
Verification
References