Help us improve
Share bugs, ideas, or general feedback.
From performance-compose-skills
Jetpack Compose side effects: LaunchedEffect, DisposableEffect, SideEffect, produceState, snapshotFlow, rememberUpdatedState, and derivedStateOf — with keys, cleanup contracts, and performance rules. Trigger: when working with LaunchedEffect, DisposableEffect, SideEffect, produceState, snapshotFlow, or any coroutine-based effect in Compose.
npx claudepluginhub santimattius/performance-compose-skills --plugin performance-compose-skillsHow this skill is triggered — by the user, by Claude, or both
Slash command
/performance-compose-skills:compose-effectsThe summary Claude sees in its skill listing — used to decide when to auto-load this skill
Every Compose frame runs three phases. Cost rises left-to-right; restart scope shrinks left-to-right.
Provides a checklist for code reviews covering functionality, security, performance, maintainability, tests, and quality. Use for pull requests, audits, team standards, and developer training.
Share bugs, ideas, or general feedback.
Every Compose frame runs three phases. Cost rises left-to-right; restart scope shrinks left-to-right.
| Phase | What Runs | Restart Cost | Trigger |
|---|---|---|---|
| Composition | Composable functions, state reads | HIGH (whole scope) | State read in composable body |
| Layout | Measure + place | MEDIUM (subtree) | State read in measure lambda |
| Drawing | Canvas commands, graphicsLayer | LOW (single node) | State read in draw/graphicsLayer lambda |
Do you need the value during composition?
T (Composition phase) — accept full restart cost() -> T into Modifier.layout/offset { } lambda() -> T into Modifier.graphicsLayer { } / drawBehind { } lambdaRule: prefer Modifier.offset { lambda } over Modifier.offset(state.value.dp). Lambda variant defers the read to the layout phase, skipping recomposition entirely.
Reading listState.firstVisibleItemIndex directly in the composable body re-runs the entire composition scope on every scroll pixel — HIGH phase cost, even if the analytics event fires only every N items.
// ❌ Reads in Composition phase — recomposes the whole scope on every scroll tick
@Composable
fun TrackScrollAnalytics(listState: LazyListState, analytics: Analytics) {
// This runs on EVERY scroll pixel — HIGH phase cost
val index = listState.firstVisibleItemIndex
SideEffect { analytics.track("scroll", index) }
}
// ✅ snapshotFlow reads state OUTSIDE the composition phase
// Fires only when firstVisibleItemIndex actually changes (.distinctUntilChanged())
@Composable
fun TrackScrollAnalytics(listState: LazyListState, analytics: Analytics) {
LaunchedEffect(listState) {
snapshotFlow { listState.firstVisibleItemIndex }
.distinctUntilChanged()
.collect { index -> analytics.track("scroll", index) }
}
}
snapshotFlow reads Compose state inside a coroutine, outside the Composition phase. The distinctUntilChanged() operator ensures downstream processing (analytics, network calls) runs only when the value actually changes — not on every scroll pixel.
WARNING: NEVER profile in debug builds. Debug disables R8, inlining, and Strong Skipping — numbers are meaningless. Always profile a release build with
profileableenabled or a benchmark build type.
| Tool | Use For | When |
|---|---|---|
| Baseline Profiles | AOT-compile critical paths (startup, scroll) | Ship in release; regenerate per release |
| Compose Compiler Reports | Detect unstable params, restartable/skippable status | Every PR; fail CI on new unstable types |
| Layout Inspector (recomposition counts) | See which composables recompose and why | When debugging excess recomposition |
| Composition Tracing | Frame-level composition timing in Android Studio | When Layout Inspector is not enough |
| Macrobenchmark | Measure startup, frame timing, jank in release | Per-release regression gate |
Use this skill when:
LaunchedEffect, DisposableEffect, or SideEffect and unsure which fits the situationState with produceStateLaunchedEffect and rememberCoroutineScopecompose-composition-core covers state lifecycle, recomposition mechanics, and stability — everything that happens during the Composition phase itself.
compose-effects covers operations TRIGGERED by composition but that run OUTSIDE it: coroutines, subscriptions, cleanup, and one-shot side effects.
Use both when:
collectAsStateWithLifecycle bridges a StateFlow into composition (architecture concern) while LaunchedEffect drives animation or analytics (effects concern)derivedStateOf inside remember (core) is combined with snapshotFlow inside LaunchedEffect (effects)derivedStateOf output — the derivedStateOf optimization belongs in core, the coroutine reaction belongs hereCanonical CMP rules:
../_shared/cmp-platform.md
| Source set | Status | Notes |
|---|---|---|
commonMain | ⚠️ | LaunchedEffect, DisposableEffect, SideEffect, snapshotFlow all ✅; collectAsStateWithLifecycle version-gated |
androidMain | ✅ | Full skill content applies |
iosMain | ⚠️ | collectAsStateWithLifecycle requires lifecycle-runtime-compose ≥ 2.8 |
desktopMain | ⚠️ | Same lifecycle version gate; also needs kotlinx-coroutines-swing in jvmMain |
wasmJsMain | ⚠️ | Same lifecycle version gate |
Status legend: ✅ fully supported · ⚠️ partial / version-gated · ❌ Android-only.
If using in CMP: collectAsStateWithLifecycle requires androidx.lifecycle:lifecycle-runtime-compose ≥ 2.8 in commonMain. Below 2.8, use collectAsState() or a expect/actual shim. See ../_shared/cmp-platform.md#4-lifecycle--state-collection.
LaunchedEffect and DisposableEffect restart when ANY key changes. Every variable the effect body reads MUST appear as a key. A stale closure from incomplete keys is the #1 effects bug.
// ❌ Bug: userId changes, but the effect never restarts — stale userId in the coroutine
@Composable
fun UserProfile(userId: String) {
LaunchedEffect(Unit) { // WRONG key — Unit never changes
loadUser(userId) // stale closure — reads the initial userId forever
}
}
// ✅ Correct: effect restarts whenever userId changes
@Composable
fun UserProfile(userId: String) {
LaunchedEffect(userId) {
loadUser(userId)
}
}
Rule: if the effect reads it, the effect keys it.
rememberUpdatedState — Callback Updates Without RestartingWhen a callback must update silently without restarting an expensive effect, keep the key stable and capture the callback via rememberUpdatedState.
// ❌ Problem: every callback lambda change restarts the timer effect
@Composable
fun Timer(onTimeout: () -> Unit) {
LaunchedEffect(onTimeout) { // callback identity changes on every recomposition
delay(5_000)
onTimeout()
}
}
// ✅ Fix: stable key — callback updates silently inside the long-running effect
@Composable
fun Timer(onTimeout: () -> Unit) {
val latestOnTimeout by rememberUpdatedState(onTimeout)
LaunchedEffect(Unit) { // key is stable — effect runs once for the composable's lifetime
delay(5_000)
latestOnTimeout() // always calls the latest lambda
}
}
DisposableEffect — Non-Empty onDispose ContractThe compiler enforces that onDispose {} is present. Its body MUST reverse the registration — remove the observer, cancel the subscription. An empty onDispose {} signals the wrong effect choice.
// ✅ Correct: lifecycle observer registered and always removed
@Composable
fun LifecycleObserverEffect(lifecycle: Lifecycle, observer: LifecycleObserver) {
DisposableEffect(lifecycle) {
lifecycle.addObserver(observer)
onDispose {
lifecycle.removeObserver(observer) // reversal — REQUIRED
}
}
}
// ❌ Wrong: empty onDispose → observer is never removed → resource leak
@Composable
fun LeakyEffect(lifecycle: Lifecycle, observer: LifecycleObserver) {
DisposableEffect(lifecycle) {
lifecycle.addObserver(observer)
onDispose {} // empty → use LaunchedEffect if you have no cleanup
}
}
Decision: if you have no cleanup, use LaunchedEffect. DisposableEffect exists ONLY when cleanup is required.
snapshotFlow + distinctUntilChanged()snapshotFlow converts Compose state into a Flow with full Flow operators. It reads state outside the Composition phase. Always pair with .distinctUntilChanged() to prevent redundant downstream processing.
@Composable
fun ScrollTracker(listState: LazyListState) {
LaunchedEffect(listState) {
snapshotFlow { listState.firstVisibleItemIndex }
.distinctUntilChanged() // skip if index did not actually change
.filter { index -> index > 0 } // Flow operators compose freely
.collect { index ->
// Fires only when firstVisibleItemIndex changes to a value > 0
analytics.track("scroll_past_top", index)
}
}
}
Prefer snapshotFlow over SideEffect for any state that changes at scroll/animation frequency. SideEffect runs after EVERY recomposition — snapshotFlow runs only when the observed value changes.
derivedStateOf Inside remember — Mandatory Wrapperval showButton by remember { derivedStateOf { listState.firstVisibleItemIndex > 0 } }. The remember wrapper is mandatory — without it, a new derivedStateOf object is created on every recomposition, defeating the optimization entirely.
// ✅ Correct: one derivedStateOf object, recomposes only when boolean result flips
@Composable
fun ScrollToTopButton(listState: LazyListState) {
val showButton by remember { derivedStateOf { listState.firstVisibleItemIndex > 0 } }
if (showButton) { /* render button */ }
}
// ❌ Bug: new derivedStateOf on every recomposition — no optimization, extra object allocation
@Composable
fun ScrollToTopButtonBad(listState: LazyListState) {
val showButton by derivedStateOf { listState.firstVisibleItemIndex > 0 }
if (showButton) { /* render button */ }
}
Only use derivedStateOf when the output changes LESS FREQUENTLY than the inputs.
SideEffect — Lightweight OnlySideEffect runs after every SUCCESSFUL recomposition. Use exclusively for synchronizing non-Compose objects with the current composition state (analytics user properties, external SDK state).
// ✅ Correct: analytics user property stays in sync with composition state
@Composable
fun TrackCurrentScreen(screenName: String, analytics: Analytics) {
SideEffect {
analytics.setCurrentScreen(screenName) // cheap, non-Compose sync
}
}
NEVER use SideEffect for high-frequency state (scroll position, animation progress). Use snapshotFlow inside LaunchedEffect instead. SideEffect has no filtering — it fires on every recomposition.
LaunchedEffect vs rememberCoroutineScopeBoth launch coroutines. The choice depends on what drives the work:
LaunchedEffect | rememberCoroutineScope | |
|---|---|---|
| Trigger | Composable entering composition | User event (button click) |
| Cancellation | Keys change OR composable leaves | Composable leaves |
| Key control | YES — restart on key change | NO — runs until scope cancelled |
| Use for | Lifecycle-scoped work (data load, animation) | Event-driven work (submit, navigate) |
// ✅ LaunchedEffect — starts with composable, restarts when userId changes
@Composable
fun ProfileScreen(userId: String, viewModel: ProfileViewModel) {
LaunchedEffect(userId) {
viewModel.loadProfile(userId)
}
}
// ✅ rememberCoroutineScope — event-driven, tied to button click
@Composable
fun SubmitButton(viewModel: FormViewModel) {
val scope = rememberCoroutineScope()
Button(onClick = {
scope.launch { viewModel.submit() } // fires on click, not on composition
}) { Text("Submit") }
}
// ❌ Wrong: rememberCoroutineScope for lifecycle work — no restart on key change
@Composable
fun ProfileScreenBad(userId: String, viewModel: ProfileViewModel) {
val scope = rememberCoroutineScope()
scope.launch { viewModel.loadProfile(userId) } // called on EVERY recomposition
}
produceState — External Observable SourcesConverts non-Compose observable sources (callbacks, futures, Rx) into Compose State. Keys behave identically to LaunchedEffect keys.
// ✅ Convert a callback-based API into Compose State
@Composable
fun locationState(locationManager: LocationManager): State<Location?> =
produceState<Location?>(initialValue = null, locationManager) {
val listener = LocationListener { location -> value = location }
locationManager.requestLocationUpdates(listener)
awaitDispose { locationManager.removeUpdates(listener) } // cleanup on key change
}
produceState is the idiomatic bridge for non-Flow, non-StateFlow observable sources. For StateFlow and SharedFlow, prefer collectAsStateWithLifecycle from the lifecycle-runtime-compose artifact.
| Pitfall | Fix | Phase Cost |
|---|---|---|
Reading listState.firstVisibleItemIndex in composable body | snapshotFlow { listState.firstVisibleItemIndex }.distinctUntilChanged() in LaunchedEffect | Eliminates Composition rerun on every scroll pixel |
LaunchedEffect(Unit) with variables that change | Keys = all variables the effect reads | Stale closure — effect never sees new values |
| Callback updates force effect restart | rememberUpdatedState(callback) — key stays stable, callback updates silently | Prevents costly effect cancel/relaunch |
DisposableEffect with empty onDispose {} | Use LaunchedEffect if no cleanup needed; add real cleanup to onDispose | Resource leak (observer never removed) |
SideEffect { analytics.track(state) } on scroll | snapshotFlow { state }.distinctUntilChanged().collect { analytics.track(it) } | Composition overhead on every frame |
derivedStateOf { ... } missing remember wrapper | Always wrap: val x by remember { derivedStateOf { ... } } | New derivedStateOf object every recomposition |
Long I/O work in LaunchedEffect on Dispatchers.Main | Switch to Dispatchers.IO inside; write state back on Main | Main thread block → UI jank |
snapshotFlow@Composable
fun ProductList(
products: List<Product>,
analytics: Analytics
) {
val listState = rememberLazyListState()
// ✅ snapshotFlow: reads outside Composition, fires only on actual change
LaunchedEffect(listState) {
snapshotFlow { listState.firstVisibleItemIndex }
.distinctUntilChanged()
.collect { index -> analytics.track("visible_product", products[index].id) }
}
// ✅ derivedStateOf: show button only when boolean flips (not every scroll pixel)
val showScrollToTop by remember { derivedStateOf { listState.firstVisibleItemIndex > 0 } }
Box {
LazyColumn(state = listState) {
items(products, key = { it.id }) { product ->
ProductCard(product)
}
}
if (showScrollToTop) {
ScrollToTopButton(onClick = { /* scope.launch { listState.animateScrollToItem(0) } */ })
}
}
}
DisposableEffect@Composable
fun LifecycleAwareScreen(
lifecycle: Lifecycle = LocalLifecycleOwner.current.lifecycle,
onResume: () -> Unit,
onPause: () -> Unit
) {
// ✅ rememberUpdatedState: callbacks update silently without restarting the effect
val latestOnResume by rememberUpdatedState(onResume)
val latestOnPause by rememberUpdatedState(onPause)
DisposableEffect(lifecycle) {
val observer = LifecycleEventObserver { _, event ->
when (event) {
Lifecycle.Event.ON_RESUME -> latestOnResume()
Lifecycle.Event.ON_PAUSE -> latestOnPause()
else -> Unit
}
}
lifecycle.addObserver(observer)
onDispose { lifecycle.removeObserver(observer) } // mandatory cleanup
}
}
produceState for Callback API@Composable
fun NetworkStatusBanner(connectivityManager: ConnectivityManager) {
val isConnected by produceState(initialValue = true, connectivityManager) {
val callback = object : ConnectivityManager.NetworkCallback() {
override fun onAvailable(network: Network) { value = true }
override fun onLost(network: Network) { value = false }
}
connectivityManager.registerDefaultNetworkCallback(callback)
awaitDispose { connectivityManager.unregisterNetworkCallback(callback) }
}
if (!isConnected) {
Banner(message = "No internet connection")
}
}
There are no CLI commands specific to Compose effects. Use the Performance Toolchain above:
freeCompilerArgs += ["-P", "plugin:androidx.compose.compiler.plugins.kotlin:reportsDestination=..."] to build.gradle.kts — identifies restartable/skippable composable status.snapshotFlow + distinctUntilChanged() reduces recompositions vs. direct state reads.| Skill | Path | What It Adds |
|---|---|---|
compose-composition-core | ../compose-composition-core/SKILL.md | State fundamentals, derivedStateOf, remember — effects build on these |
compose-animations | ../compose-animations/SKILL.md | Animation effects that use LaunchedEffect + Animatable |
| File | Covers |
|---|---|
| references/README.md | Index of all reference files |
| references/side-effects-catalog.md | Effect selection, LaunchedEffect, DisposableEffect, produceState |
| references/snapshot-flow-and-derived-state.md | snapshotFlow, derivedStateOf, rememberUpdatedState |
LaunchedEffect, DisposableEffect, SideEffect, produceState, rememberUpdatedState, rememberCoroutineScope, snapshotFlow, derivedStateOfDisposableEffect compiler enforcement — onDispose {} block is required by the Compose compiler; omitting it is a compile error