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.
How 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.
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 errorProvides a checklist for code reviews covering functionality, security, performance, maintainability, tests, and quality. Use for pull requests, audits, team standards, and developer training.
npx claudepluginhub santimattius/performance-compose-skills --plugin performance-compose-skills