Claude
Skills
Sign in
Back

compose-side-effects

Included with Lifetime
$97 forever

Use when writing or reviewing Jetpack Compose code with LaunchedEffect, DisposableEffect, SideEffect, rememberCoroutineScope, rememberUpdatedState, snapshotFlow, snackbar, navigation, focus requests, analytics, or event Flow collection.

Data & Analytics

What this skill does


# 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` values are stale inside `remember {}` blocks

`rememberUpdatedState` returns a `State` object whose `.value` is updated on every recomposition. The "latest" behavior only helps when the State is **read lazily** — inside an effect body or a lambda that runs later — not when the value is captured eagerly.

Inside a `remember {}` block the producer lambda runs once. Reading the delegate there snapshots the current `.value` into the remembered object — future State updates never reach it:

```kotlin
val latestChannelId by rememberUpdatedState(channelId)

// ❌ BAD — channelId is read once when remember's lambda executes;
// the destination holds the initial value forever
val destination = remember {
    Destination(channelId = latestChannelId)
}

// ✅ GOOD — skip rememberUpdatedState; key remember on the changing value
val destination = remember(channelId) {
    Destination(channelId = channelId)
}

// ✅ ALSO GOOD — wrapping lambda defers the read to each invocation
val destination = remember {
    Destination(channelId = { latestChannelId })
}
```

The same trap applies anywhere a `rememberUpdatedState` delegate is **read eagerly** rather than deferred behind a lambda or effect body: data classes constructed in `remember`, objects built once in `DisposableEffect`'s setup block, or any expression evaluated at creation time.

When the captured value should trigger recreation of the remembered object, make it a `remember` key and skip `rememberUpdatedState` entirely. Reserve `rememberUpdatedState` for values that must stay fresh inside a long-lived scope (effect coroutine, event callback) **without** restarting that scope.

`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 | Diagnosis | Fix |
|---|---|---|
| Network request directly in the composable body | Side work in composition | Usually move to a ViewModel/state holder; use `LaunchedEffect` only for UI-owned keyed work |
| Analytics property written from the composable body | Side work in composition | Use `SideEffect` when it should publish after every successful recomposition |
| Impression/event logged from the composable body | Side work in composition | Use `LaunchedEffect(key)` when it should run once for that key |
| `LaunchedEffect(Unit)` captures changing `id` | Missing key | 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 |
| `rememberUpdatedState` delegate read directly in `remember {}` (e.g. `Destination(id = latestId)`) | Value captured once, never 

Related in Data & Analytics