Collecting Flows Safely
Communityskydoves
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.
Overview
- Repository
- skydoves/compose-performance-skills
- Publisher
- skydoves
- Stars
- 531
- Forks
- 20
- Bundled files
- 0
- License
- Apache-2.0
- Updated
- Jun 16, 2026
Installation
tmp=$(mktemp -d)
git clone --depth 1 https://github.com/skydoves/compose-performance-skills.git "$tmp"
mkdir -p ~/.claude/skills
cp -R "$tmp/side-effects/collecting-flows-safely" ~/.claude/skills/collecting-flows-safely
rm -rf "$tmp"tmp=$(mktemp -d)
git clone --depth 1 https://github.com/skydoves/compose-performance-skills.git "$tmp"
mkdir -p ~/.codex/skills
cp -R "$tmp/side-effects/collecting-flows-safely" ~/.codex/skills/collecting-flows-safely
rm -rf "$tmp"Install the Collecting Flows Safely agent skill from https://github.com/skydoves/compose-performance-skills/tree/main/side-effects/collecting-flows-safely into this project's skills folder.Instructions
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
ViewModelorRepositoryexposesStateFlow<T>,SharedFlow<T>, or a coldFlow<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
collectAsStatevscollectAsStateWithLifecycle, or aboutLifecycle.State.STARTED/RESUMEDsemantics. - 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, prefermutableStateOfdirectly. See../using-efficient-effects/SKILL.mdfor 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 callcollectAsStateWithLifecycle(). - A truly always-on background listener that must run while the Activity is
STOPPED. Move that work to aService/WorkManager/repeatOnLifecyclein the Activity, not into Compose.
Prerequisites
- Compose UI 1.4+ (any modern release).
- Add
androidx.lifecycle:lifecycle-runtime-compose(2.6+; the artifact that exposescollectAsStateWithLifecycle). Maven coordinates:androidx.lifecycle:lifecycle-runtime-compose:<version>. - Kotlin coroutines basics (
StateFlow,SharedFlow,conflate,distinctUntilChanged). - Read
../../recomposition/deferring-state-reads/SKILL.mdif 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 withcollectAsStateWithLifecycle()unless the producing flow is created inside the same composable scope. - Provide an
initialValuewhen the flow is not aStateFlow(coldFlow<T>orSharedFlow<T>). ForStateFlow<T>, the overload reads.value— no initial value needed. - Pick the right
minActiveState. DefaultSTARTEDmatches the framework'srepeatOnLifecycledefault. UseRESUMEDfor widgets that should only collect while the Activity owns input focus (e.g. always-visible foreground HUD with an aggressive sensor source). Do not useCREATED— that defeats the purpose. - For high-frequency producers (>1 emission per ~100 ms), add
.conflate()upstream ofcollectAsStateWithLifecycle()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 correctequals(). - Hoist
Flow<T>parameters out of composables. Replacefun Foo(prices: Flow<Price>)withfun Foo(price: Price). Collect at the caller. If the producer must stay private to the parent, expose a() -> Pricelambda provider rather than the rawFlow. - For State → Flow direction, use
snapshotFlow { ... }inside aLaunchedEffect. 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
// 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.// RIGHT
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
// 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)
}Pattern: do not pass Flow<T> as a composable parameter (skydoves hot take #4)
// 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).// 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)
}Pattern: high-frequency flow needs .conflate() + .distinctUntilChanged()
// 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.// RIGHT
@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
// 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.Pattern: prefer snapshotFlow over derivedStateOf for fire-and-forget reactions
// LESS PREFERRED for an effect that only emits side effects
val isAtTop by remember { derivedStateOf { listState.firstVisibleItemIndex == 0 } }
LaunchedEffect(isAtTop) { if (isAtTop) reportTop() }// PREFERRED — no derived state slot in composition; reads happen in the coroutine
LaunchedEffect(listState) {
snapshotFlow { listState.firstVisibleItemIndex == 0 }
.distinctUntilChanged()
.filter { it }
.collect { reportTop() }
}Mandatory rules
- MUST call
collectAsStateWithLifecycle()(fromandroidx.lifecycle:lifecycle-runtime-compose) for anyFlowthat 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 resolvedT(or a() -> Tlambda provider for very hot producers). Flow is unstable and blocks skipping for the entire composable. - MUST add
.conflate()and/or.distinctUntilChanged()upstream ofcollectAsStateWithLifecycle()for any flow emitting more often than ~once per 100 ms. Ensureequals()is correct on the emitted type when usingdistinctUntilChanged. - 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.CREATEDforminActiveState— it leaves collection running while the activity is invisible, defeating the migration. - MUST NOT rewrap an existing
State<T>into a flow just to callcollectAsStateWithLifecycle(). Keep theStatedirect. - PREFERRED:
snapshotFlow { ... }overderivedStateOf { ... }when the only consumer is a fire-and-forget effect (analytics, logging, side-channel emit) instead of UI.
Verification
-
grep -R "collectAsState(" src/returns 0 hits, or each remaining hit collects a flow created inside the same composable. - Background the app and watch logcat: upstream operators (
Repositorylog 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. - Battery Historian / Studio Energy Profiler shows no foreground-only work attributed to the screen while the app is backgrounded.
- No composable in the module declares a parameter of type
Flow<*>(grep -R ": Flow<" src/). - When
minActiveState = RESUMEDis used, the rationale (focus-required widget) is documented in code.
References
- Manuel Vivo — Consuming flows safely in Jetpack Compose: https://medium.com/androiddevelopers/consuming-flows-safely-in-jetpack-compose-cde014d0d5a3
- Android Developers — Lifecycle-aware coroutines (
repeatOnLifecycle): https://developer.android.com/topic/libraries/architecture/coroutines androidx.lifecycle:lifecycle-runtime-composerelease notes: https://developer.android.com/jetpack/androidx/releases/lifecycle- Android Developers — Compose side effects (
snapshotFlow,LaunchedEffect): https://developer.android.com/develop/ui/compose/side-effects - Skydoves — 6 Jetpack Compose Guidelines (Flow-as-parameter antipattern): https://medium.com/proandroiddev/6-jetpack-compose-guidelines-to-optimize-your-app-performance-be18533721f9
- Ben Trengrove — Why test perf in release: https://medium.com/androiddevelopers/why-should-you-always-test-compose-performance-in-release-4168dd0f2c71
- Sibling skill:
../using-efficient-effects/SKILL.mdforLaunchedEffect/DisposableEffect/RememberedEffectselection. - Sibling skill:
../../measurement/tracing-recompositions-at-runtime/SKILL.mdfor verifying emission counts at runtime.
FAQ
What does Collecting Flows Safely do?
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.
Who made Collecting Flows Safely?
skydoves (a community member) publishes it in the skydoves/compose-performance-skills repository on GitHub.
Is Collecting Flows Safely free?
Yes. Collecting Flows Safely is open source, published by skydoves under the Apache-2.0 license.
What comes with Collecting Flows Safely?
Just its instructions. Everything the agent needs is in one SKILL.md file.
Which agents can use Collecting Flows Safely?
Any coding agent that reads SKILL.md files, including Claude Code, Codex, Cursor, GitHub Copilot and Gemini CLI. The agent loads the skill when a task matches its description.
What are agent skills?
An agent skill is a folder with a SKILL.md file: instructions that teach a coding agent how to do one kind of task, and optionally files it can read while it works. The agent keeps only the skill's name and description in mind and reads the rest when a task calls for it.
How are agent skills different from MCP servers?
An MCP server gives an agent new tools to call, code that runs somewhere and returns results. A skill gives it knowledge: how to approach a task with the tools it already has. Many agents use both.
How can my agent test my Android app?
Robomori lets coding agents create, start and control Android emulators and devices from one command, so they can install your app, take screenshots and check dark mode, font sizes and languages.