Spot side-effect bugs in Compose code

Reviews Jetpack Compose code for common side-effect pitfalls—missing dependencies, stale closures, lifecycle mismatches—and suggests fixes with explanations.

Best for: Android engineers shipping Compose features and wanting to avoid runtime crashes from effect bugs.

Engineering / code-reviewatomicfor-engineersno-setupfrom-file

Topics

androidcompose-multiplatformcoroutinesjetpack-composekotlinkotlin-multiplatformskills

Source

Creator's repository · chrisbanes/skills

View on GitHub

License: Apache-2.0

Skill file

Preview skill file
---
name: compose-side-effects
description: Use when writing or reviewing Jetpack Compose code with LaunchedEffect, DisposableEffect, SideEffect, rememberCoroutineScope, rememberUpdatedState, snapshotFlow, snackbar, navigation, focus requests, analytics, or event Flow collection.
---

# Compose: side effects

## Core principle

Composable bodies describe UI. They can be recomposed, skipped, or abandoned. Work that changes the outside world belongs in an effect API whose lifecycle matches the work.

## Pick the smallest effect

| Need | API |
|---|---|
| Publish Compose state to non-Compose code after every successful recomposition | `SideEffect` |
| Register/unregister a listener, callback, observer, or resource | `DisposableEffect(keys...)` |
| Run suspending, deferred, or keyed one-shot work | `LaunchedEffect(keys...)` |
| Launch suspending work from a user event callback | `rememberCoroutineScope()` |
| Convert Compose snapshot reads into a Flow inside a coroutine | `snapshotFlow { ... }` inside `LaunchedEffect` |

## Effect keys

Keys define restart identity. When any key changes, the old effect is cancelled/disposed and a new one starts.

```kotlin
// ✅ Restart collection when userId changes
LaunchedEffect(userId) {
    repository.events(userId).collect { event -> handle(event) }
}

// ❌ Unit hides a changing input; collection keeps using the first userId
LaunchedEffect(Unit) {
    repository.events(userId).collect { event -> handle(event) }
}
```

Use stable, semantic keys:

- Use the thing whose lifecycle the effect follows: `userId`, `screenId`, `lifecycleOwner`, `focusRequester`.
- Do not use broad objects (`state`, `viewModel`) when only one property matters.
- Do not add changing lambdas as keys unless you really want restarts on every lambda change.

## Avoid stale captures

For long-running effects that should not restart but need the latest callback or value, use `rememberUpdatedState`.

```kotlin
@Composable
fun Timeout(onTimeout: () -> Unit) {
    val latestOnTimeout by rememberUpdatedState(onTimeout)

    LaunchedEffect(Unit) {
        delay(1_000)
        latestOnTimeout()
    }
}
```

Use this when the lifecycle is "start once" but the invoked lambda should stay fresh. Common cases:

- A timeout or splash effect should not restart when `onTimeout` changes, but it should call the latest callback.
- A lifecycle observer should stay registered to the same owner, but invoke the latest `onStart` / `onStop` lambdas.
- A long-running collector should keep its collection lifecycle, but call the latest event handler.

Do not use `rememberUpdatedState` to avoid choosing proper keys. If the changed value should restart the work, make it a key instead:

```kotlin
// BAD: userId changes should restart the collection, not update a captured value.
val latestUserId by rememberUpdatedState(userId)
LaunchedEffect(Unit) {
    repository.events(latestUserId).collect { event -> handle(event) }
}

// GOOD: the collection lifecycle follows userId.
LaunchedEffect(userId) {
    repository.events(userId).collect { event -> handle(event) }
}
```

`rememberUpdatedState` also does not make render state "non-recomposing." If the UI needs to display a changing value, read normal `State` in composition or use the deferred-read patterns in [`compose-state-deferred-reads`](../compose-state-deferred-reads/SKILL.md) for frame-rate values.

## Collecting Flow

Use `LaunchedEffect` for **side-effect/event flows**: snackbars, navigation events, analytics events, focus commands, or other streams where each emission triggers imperative work.

```kotlin
LaunchedEffect(events) {
    events.collect { event ->
        snackbarHostState.showSnackbar(event.message)
    }
}
```

Do not collect render state imperatively just to mutate local state. For UI state, collect near the state holder and pass plain values into the UI composable—the **state-holder vs UI split**, `collectAsStateWithLifecycle()` / `collectAsState()`, and preview-friendly wiring are covered in [`compose-state-holder-ui-split`](../compose-state-holder-ui-split/SKILL.md). Do not duplicate that architecture here.

On Android, prefer lifecycle-aware collection where available; use `collectAsState()` on targets without lifecycle-aware APIs.

For Compose state reads, use `snapshotFlow`:

```kotlin
LaunchedEffect(listState) {
    snapshotFlow { listState.firstVisibleItemIndex }
        .distinctUntilChanged()
        .collect { index -> analytics.visibleIndex(index) }
}
```

`snapshotFlow { ... }.map { ... }` without a terminal `collect` does nothing.

## User events

Use `rememberCoroutineScope()` when a click or gesture starts suspending work:

```kotlin
@Composable
fun SaveButton(snackbarHostState: SnackbarHostState) {
    val scope = rememberCoroutineScope()

    Button(
        onClick = {
            scope.launch {
                snackbarHostState.showSnackbar("Saved")
            }
        },
    ) {
        Text("Save")
    }
}
```

Avoid "event flag" state just to trigger a `LaunchedEffect`. The click already is the event.

## Registration and cleanup

Use `DisposableEffect` for paired setup/teardown:

```kotlin
@Composable
fun ObserveLifecycle(owner: LifecycleOwner, observer: LifecycleObserver) {
    DisposableEffect(owner, observer) {
        owner.lifecycle.addObserver(observer)
        onDispose {
            owner.lifecycle.removeObserver(observer)
        }
    }
}
```

Every registration path should have a matching `onDispose` cleanup path.

## Common mistakes

| Mistake | Fix |
|---|---|
| Network request directly in the composable body | Usually move to a ViewModel/state holder; use `LaunchedEffect` only for UI-owned keyed work |
| Analytics property written from the composable body | Use `SideEffect` when it should publish after every successful recomposition |
| Impression/event logged from the composable body | Use `LaunchedEffect(key)` when it should run once for that key |
| `LaunchedEffect(Unit)` captures changing `id` | Key by `id`, or use `rememberUpdatedState` if it must not restart |
| `rememberUpdatedState(id)` used so `LaunchedEffect(Unit)` keeps running after `id` changes | Hidden lifecycle bug | Key the effect by `id` |
| Long-lived effect invokes an old callback after recomposition | Stale capture | Wrap the callback with `rememberUpdatedState` and call the wrapper inside the effect |
| `LaunchedEffect(state) { ... }` restarts too often | Key by the specific property |
| `LaunchedEffect(...) { nonSuspendSetter() }` | Usually `SideEffect`; keep `LaunchedEffect` only for keyed one-shot/deferred work |
| Listener added in `LaunchedEffect` with no cleanup | Use `DisposableEffect` |
| Launching from click by setting `shouldShowSnackbar = true` | Use `rememberCoroutineScope()` in the click callback |
| `if (isFocused) { … }` or focus read in composable body for side work | Side work during composition | `LaunchedEffect(focused) { … }` or `snapshotFlow` |
| `onSizeChanged { heightState = it.height }` on measured composable | OK in isolation — but layout → composition back-write if a sibling reads `heightState` in composition | Siblings must consume height in measure phase, not `Modifier.height(state.dp)` in composition |

## Focus and measurement

**Focus:** Reading focus in the composable body to drive **side work** (preloading, analytics, toasts) runs that work during composition. Observe focus in an effect instead:

```kotlin
// ❌ BAD — side work runs during composition every time `focused` is true,
// including transient focus passes; `SideEffect` re-runs after every successful recomposition
@Composable
fun Preloader(interactionSource: MutableInteractionSource) {
    val focused by interactionSource.collectIsFocusedAsState()
    if (focused) {
        preloadImages()
    }
}

// ✅ GOOD — side work in a keyed effect
@Composable
fun Preloader(interactionSource: MutableInteractionSource) {
    val focused by interactionSource.collectIsFocusedAsState()
    LaunchedEffect(focused) {
        if (focused) preloadImages()
    }
}
```

Use `snapshotFlow { … }` inside `LaunchedEffect` when you need to sample multiple snapshot reads or debounce rapid changes without keying the effect on every derived value. For TV/D-pad focus navigation semantics, see [`compose-focus-navigation`](../compose-focus-navigation/SKILL.md).

**Measurement:** `onSizeChanged` / `onGloballyPositioned` are valid **callbacks**, but they fire during the layout phase. Writing snapshot state there is only safe if no earlier phase reads it. If a sibling reads that state in composition, layout is back-writing into composition and the sibling will recompose every measure pass. Apply captured dimensions in `Modifier.layout` (see [`compose-modifier-and-layout-style`](../compose-modifier-and-layout-style/SKILL.md) §7 and [`compose-state-deferred-reads`](../compose-state-deferred-reads/SKILL.md)).

## Red flags during review

- "This only runs once" about code in a composable body.
- `LaunchedEffect(Unit)` in a function with changing parameters.
- A flow chain inside an effect with no terminal collection.
- Effects whose keys are chosen to silence lint instead of model lifecycle.
- Callback lambdas used from long-lived effects without either a key or `rememberUpdatedState`.