Install any skill in seconds. Free to start, no credit card required.
Get Started Free →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`, `col
.claude/skills/skydoves-collecting-flows-safely/SKILL.md| Test case | Without → With | Effect | Δ tokens | Δ turns |
|---|---|---|---|---|
| case-10 | ✗→✓ | ▲ Improved | 93% | 0% |
| case-17 | ✗→✓ | ▲ Improved | 100% | 0% |
| case-19 | ✗→✓ | ▲ Improved | 88% | 0% |
| case-09 | ✓→✓ | = Same ✓ | 358% | 0% |
| case-01 | ✓→✓ | = Same ✓ | 108% | 0% |
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.
ViewModel or Repository exposes StateFlow<T>, SharedFlow<T>, or a cold Flow<T> to a Compose screen.fun MyScreen(state: Flow<State>) (Flow-as-parameter antipattern).collectAsState vs collectAsStateWithLifecycle, or about Lifecycle.State.STARTED / RESUMED semantics.snapshotFlow.remember { ... } block — that is composition-internal state, prefer mutableStateOf directly. See ../using-efficient-effects/SKILL.md for choosing the right effect API.State<T> (e.g. mutableStateOf, Animatable.asState()) — do not wrap it in a flow just to call collectAsStateWithLifecycle().STOPPED. Move that work to a Service / WorkManager / repeatOnLifecycle in the Activity, not into Compose.androidx.lifecycle:lifecycle-runtime-compose (2.6+; the artifact that exposes collectAsStateWithLifecycle). Maven coordinates: androidx.lifecycle:lifecycle-runtime-compose:<version>.StateFlow, SharedFlow, conflate, distinctUntilChanged).../../recomposition/deferring-state-reads/SKILL.md if the high-frequency emissions are driving animation values — phase deferral may be a better fix than .conflate().collectAsState() call. Search the module for \.collectAsState\(. Replace each call with collectAsStateWithLifecycle() unless the producing flow is created inside the same composable scope.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.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..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().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.snapshotFlow { ... } inside a LaunchedEffect. That is the supported bridge from Compose's snapshot system to coroutine flows; combine with .distinctUntilChanged() to avoid spurious emissions.@TraceRecomposition (see ../../measurement/tracing-recompositions-at-runtime/SKILL.md) and a logcat sanity check with the app backgrounded — upstream emissions should stop.collectAsState with collectAsStateWithLifecyclekotlin// WRONG import androidx.compose.runtime.collectAsState @Composable fun HomeScreen(viewModel: HomeViewModel) { val state by viewModel.uiState.collectAsState() HomeContent(state) } // WRONG because: collection continues while the app is backgrounded -> wasted CPU and battery, // and any upstream operators (network polling, db queries) keep running.
kotlin// RIGHT import androidx.lifecycle.compose.collectAsStateWithLifecycle @Composable fun HomeScreen(viewModel: HomeViewModel) { val state by viewModel.uiState.collectAsStateWithLifecycle() HomeContent(state) }
minActiveState for an always-visible widgetkotlin// RIGHT 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) }
Flow<T> as a composable parameter (skydoves hot take #4)kotlin// WRONG @Composable fun PriceTicker(prices: Flow<Price>) { val price by prices.collectAsState(initial = Price.ZERO) Text(price.formatted) } // WRONG because: Flow is unstable -> blocks skipping for the whole composable; the collection // lifecycle is also unclear (recreated on every recomposition unless the caller remembers it).
kotlin// RIGHT @Composable fun PriceTicker(price: Price) { Text(price.formatted) } // Caller collects once and passes the value: @Composable fun TickerScreen(viewModel: TickerViewModel) { val price by viewModel.price.collectAsStateWithLifecycle() PriceTicker(price) }
.conflate() + .distinctUntilChanged()kotlin// WRONG @Composable fun TiltIndicator(repository: SensorRepository) { val tilt by repository.tilt.collectAsStateWithLifecycle(initialValue = 0f) TiltUi(tilt) } // WRONG because: hundreds of emissions per second invalidate the consuming composable per emission; // most are dropped frames worth of work.
kotlin// RIGHT @Composable fun TiltIndicator(repository: SensorRepository) { val flow = remember(repository) { repository.tilt.conflate().distinctUntilChanged() } val tilt by flow.collectAsStateWithLifecycle(initialValue = 0f) TiltUi(tilt) }
snapshotFlowkotlin// RIGHT @Composable fun FeedAnalytics(listState: LazyListState, analytics: Analytics) { LaunchedEffect(listState, analytics) { snapshotFlow { listState.firstVisibleItemIndex } .distinctUntilChanged() .collect { index -> analytics.logFirstVisibleIndex(index) } } } // snapshotFlow bridges snapshot State to a cold Flow without paying the cost of recomposing // every time firstVisibleItemIndex changes — the read happens inside the LaunchedEffect's coroutine, // not in the composition phase.
snapshotFlow over derivedStateOf for fire-and-forget reactionskotlin// LESS PREFERRED for an effect that only emits side effects val isAtTop by remember { derivedStateOf { listState.firstVisibleItemIndex == 0 } } LaunchedEffect(isAtTop) { if (isAtTop) reportTop() }
kotlin// PREFERRED — no derived state slot in composition; reads happen in the coroutine LaunchedEffect(listState) { snapshotFlow { listState.firstVisibleItemIndex == 0 } .distinctUntilChanged() .filter { it } .collect { reportTop() } }
collectAsStateWithLifecycle() (from androidx.lifecycle:lifecycle-runtime-compose) for any Flow that originates outside the composition (ViewModel, repository, sensor, network, websocket, location).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..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.remember(key) when applying .conflate() / .distinctUntilChanged() inline, so a new chain is not created on every recomposition.Lifecycle.State.CREATED for minActiveState — it leaves collection running while the activity is invisible, defeating the migration.State<T> into a flow just to call collectAsStateWithLifecycle(). Keep the State direct.snapshotFlow { ... } over derivedStateOf { ... } when the only consumer is a fire-and-forget effect (analytics, logging, side-channel emit) instead of UI.grep -R "collectAsState(" src/ returns 0 hits, or each remaining hit collects a flow created inside the same composable.Repository log lines, sensor callbacks) stop within one frame and resume on foregrounding.@TraceRecomposition(traceStates = true) on the consuming composable shows one recomposition per meaningful emission, not per raw upstream emission. See ../../measurement/tracing-recompositions-at-runtime/SKILL.md.Flow<*> (grep -R ": Flow<" src/).minActiveState = RESUMED is used, the rationale (focus-required widget) is documented in code.repeatOnLifecycle): https://developer.android.com/topic/libraries/architecture/coroutinesandroidx.lifecycle:lifecycle-runtime-compose release notes: https://developer.android.com/jetpack/androidx/releases/lifecyclesnapshotFlow, LaunchedEffect): https://developer.android.com/develop/ui/compose/side-effects../using-efficient-effects/SKILL.md for LaunchedEffect / DisposableEffect / RememberedEffect selection.../../measurement/tracing-recompositions-at-runtime/SKILL.md for verifying emission counts at runtime.Other measured skills in the registry, with their headline benchmark lift.