---
name: android-jetpack-compose-expert
description: "Compose runtime expertise: state ownership, recomposition and stability, side-effect APIs, lazy lists, modifier order, Modifier.Node. Use when writing or reviewing composables, or when a screen recomposes too often or loses state."
metadata:
  risk: safe
  source: multi-agent-pipeline
  date_added: "2026-09-25"
  not-for: compose-components, android-performance
---

# Jetpack Compose Runtime Expertise

Compose calls your functions again whenever state they read changes. Almost
every Compose bug is a disagreement between what the author thought would be
re-run and what the runtime actually re-runs. This skill is about that model.

Sibling skills cover the rest: Material components and theming in
`compose-components`, routes and back stack in `compose-navigation`, UI and
ViewModel tests in `compose-testing`, layering and DI in `android-architecture`,
the MVI base class in `android-mvi-viewmodel`, and profiling, Baseline Profiles
and R8 in `android-performance`.

## The three phases

A frame runs composition (what to show), layout (where and how big), then
drawing. A state read is tracked in the phase where it happens, and only that
phase and the ones after it re-run when the value changes.

| Read happens in | Re-runs on change | Typical API |
|---|---|---|
| Composable body | composition, layout, draw | `Text(text = state.title)` |
| Layout lambda | layout, draw | `Modifier.offset { IntOffset(x, 0) }` |
| Draw lambda | draw only | `Modifier.drawBehind { drawRect(color) }` |

Move fast-changing reads (scroll offsets, animation values) into the lambda
variants so they skip composition entirely:

```kotlin
// Recomposes the whole header on every scroll pixel
Header(modifier = Modifier.alpha(1f - listState.firstVisibleItemScrollOffset / 300f))

// Reads the value in the draw phase only
Header(modifier = Modifier.graphicsLayer {
    alpha = 1f - listState.firstVisibleItemScrollOffset / 300f
})
```

## Derived values

Computing something from state inside the body is fine when it is cheap. Two
cases need care:

- **Expensive work** keyed on inputs: `val sorted = remember(products, sortKey) { products.sortedWith(sortKey.comparator) }`.
- **A value that changes less often than its source**: wrap it in
  `derivedStateOf` so readers recompose only when the result flips.

```kotlin
val showScrollToTop by remember {
    derivedStateOf { listState.firstVisibleItemIndex > 0 }
}
```

Do not use `derivedStateOf` to combine two ordinary parameters; it only helps
when the source changes more often than the output.

## Stability and skipping

With strong skipping mode (default since Kotlin 2.0.20 with the Compose compiler
Gradle plugin), a restartable composable skips when every argument is equal to
the previous one: `equals` for stable types, reference identity for unstable
ones. Lambdas are remembered automatically. So the practical rules are:

- Pass immutable data. `data class Product(val id: String, val name: String)`
  with `val` properties of stable types is stable.
- `List`, `Set` and `Map` are interfaces and count as unstable. Use
  `kotlinx.collections.immutable` (`ImmutableList`) or list them in a
  compiler stability configuration file if they are never mutated.
- Classes from modules without the Compose compiler (domain modules) are
  unstable unless listed in the stability config or wrapped in a UI model.
- Do not allocate a new list or object in the parent body just to pass it down;
  identity changes and the child cannot skip.

Mark a class `@Immutable` only when it truly never changes after construction,
and `@Stable` when changes are notified through snapshot state. A wrong
annotation causes stale UI, which is worse than an extra recomposition.

Measure before annotating: the Layout Inspector recomposition counts and the
compiler reports (`reportsDestination`) show what is skipping. Tooling detail
lives in `android-performance`.

## Lazy lists

```kotlin
LazyColumn(state = listState) {
    items(
        items = state.notes,
        key = { note -> note.id },
        contentType = { note -> if (note.isPinned) "pinned" else "regular" },
    ) { note ->
        NoteRow(note = note, onClick = { onNoteClick(note.id) }, modifier = Modifier.animateItem())
    }
}
```

- `key` keeps item state and scroll position attached to the right row when
  the list reorders or items are inserted. It must be stable and unique, and
  `Bundle`-saveable (String, Int, Long).
- `contentType` lets the list reuse compositions between rows of the same shape.
- `Modifier.animateItem()` animates insertion, removal and moves; it needs keys.
- Never nest a vertically scrolling lazy list inside `verticalScroll`; give the
  inner list a fixed height or flatten it into the outer list with `item {}`
  blocks.

## CompositionLocal

Use a `CompositionLocal` for values that are ambient to a subtree and rarely
change: theme tokens, a formatter, analytics sink. Do not use it to smuggle
screen data or ViewModels past parameter lists; that hides dependencies and
makes previews fail at runtime.

- `staticCompositionLocalOf` for values that almost never change (whole subtree
  recomposes on change, reads are cheap).
- `compositionLocalOf` when the value changes and only readers should recompose.
- Always provide a sensible default or throw with a message naming the provider.

## Review checklist

- Screen composable receives state and lambdas, not a ViewModel.
- State collected with `collectAsStateWithLifecycle`.
- No side effect, logging or allocation-heavy work in a composable body.
- Every effect has keys that match its real dependencies.
- Fast-changing values read in layout or draw lambdas.
- Lazy list items have stable keys; no nested same-direction scrolling.
- Parameters are stable or the instability is measured and accepted.
- Custom modifiers use `Modifier.Node`; `modifier` parameter applied once.
- User-facing strings via `stringResource`, sizes via theme or dimension tokens.

## References

- `references/patterns.md`: state holders and hoisting, the side-effect API table with key rules, and modifier order plus Modifier.Node. Load it when the task reaches those topics.
