Build Android UIs with Jetpack Compose: composition, state hoisting, remember/rememberSaveable/derivedStateOf, recomposition correctness and performance, side-effect APIs (LaunchedEffect/DisposableEffect/rememberCoroutineScope/produceState/snapshotFlow), Material 3 theming, and Compose navigation. Use when writing or reviewing @Composable functions, fixing recomposition or state bugs, hoisting state, choosing a side-effect API, applying Material 3, or improving Compose performance and stability.
Scanned 9/2/2026
Install to Claude Code
npx -y skills add eric-sabe/engsys --skill jetpack-compose --agent claude-codeInstalls into .claude/skills of the current project.
Are you the author of Jetpack Compose?
Add the live security badge to your README — it updates automatically with every re-scan.
[](https://www.skillsdirectory.com/skills/eric-sabe-jetpack-compose)More formats (shields.io, HTML) on the badges page.
---
name: jetpack-compose
description: "Build Android UIs with Jetpack Compose: composition, state hoisting, remember/rememberSaveable/derivedStateOf, recomposition correctness and performance, side-effect APIs (LaunchedEffect/DisposableEffect/rememberCoroutineScope/produceState/snapshotFlow), Material 3 theming, and Compose navigation. Use when writing or reviewing @Composable functions, fixing recomposition or state bugs, hoisting state, choosing a side-effect API, applying Material 3, or improving Compose performance and stability."
---
# Jetpack Compose
Build and review Android UIs with Jetpack Compose targeting the current Compose
BOM (2025.x) and Material 3. Composables are pure descriptions of UI for a given
state; the runtime re-invokes them (recomposes) when the state they read
changes. Treat composition as a function of state, keep composables side-effect
free except through the official effect APIs, and hoist state so UI is testable
and reusable. This is Android's analogue of declarative SwiftUI.
## Contents
- [Composition Model](#composition-model)
- [State and remember](#state-and-remember)
- [State Hoisting](#state-hoisting)
- [derivedStateOf](#derivedstateof)
- [Recomposition Pitfalls](#recomposition-pitfalls)
- [Side-Effect APIs](#side-effect-apis)
- [Collecting Flows](#collecting-flows)
- [Stability and Performance](#stability-and-performance)
- [Material 3](#material-3)
- [ViewModel Integration](#viewmodel-integration)
- [Common Mistakes](#common-mistakes)
- [Review Checklist](#review-checklist)
## Composition Model
- A `@Composable` function emits UI. It may run often, in any order, in parallel,
and be skipped. **It must be idempotent and free of side effects.**
- Never mutate external state, perform IO, or launch work directly in the body of
a composable. Use the effect APIs for anything that must happen *as a result*
of composition.
- Recomposition is driven by reads of *snapshot state* (`State<T>` /
`MutableState<T>`). Reading a state value subscribes that composition scope to
it; when it changes, only scopes that read it recompose.
## State and remember
| API | Use for |
|---|---|
| `remember { }` | Keep an object across recompositions (not config changes). |
| `rememberSaveable { }` | Survive config changes and process death (Bundle-able). |
| `mutableStateOf(x)` | Observable state that triggers recomposition on change. |
| `derivedStateOf { }` | Computed state derived from other state (see below). |
| `produceState` | Convert non-Compose async source into `State`. |
```kotlin
@Composable
fun Counter() {
var count by rememberSaveable { mutableStateOf(0) } // survives rotation
Button(onClick = { count++ }) { Text("Count: $count") }
}
```
- Use `by` delegation (`var x by remember { mutableStateOf(...) }`) for ergonomic
reads/writes.
- `remember(key1, key2) { ... }` recomputes when a key changes — use keys to
reset remembered state when inputs change.
- Prefer `rememberSaveable` for anything the user would be annoyed to lose on
rotation (text input, scroll selection, expanded state).
## State Hoisting
Make composables **stateless** by lifting state up to the caller. A hoisted
composable takes the value as a parameter and exposes changes via a callback —
the "state down, events up" pattern.
```kotlin
// Stateless, reusable, testable
@Composable
fun SearchField(query: String, onQueryChange: (String) -> Unit) {
TextField(value = query, onValueChange = onQueryChange)
}
// Stateful caller owns the state
@Composable
fun SearchScreen(viewModel: SearchViewModel) {
val state by viewModel.uiState.collectAsStateWithLifecycle()
SearchField(query = state.query, onQueryChange = viewModel::onQueryChange)
}
```
Hoist to the lowest common ancestor that needs the state. State that belongs to
the screen's logic lives in the `ViewModel`; ephemeral UI state (animations,
focus, scroll) can stay local with `remember`.
## derivedStateOf
Use `derivedStateOf` when state should be *computed* from other state and you
want to recompute only when the **result** changes, not on every read of the
inputs. The classic case: deriving a boolean from a frequently changing value.
```kotlin
val listState = rememberLazyListState()
// Recomposes only when the boolean flips, not on every scroll pixel.
val showButton by remember {
derivedStateOf { listState.firstVisibleItemIndex > 0 }
}
```
Do **not** use `derivedStateOf` for a plain transformation of a single state that
changes at the same rate as the result (e.g. `val upper = text.uppercase()`) —
that is just a normal calculation and the extra machinery is wasteful.
## Recomposition Pitfalls
- **Reading state too high** hurts performance; read it as deep as possible so
fewer composables recompose. Defer reads with lambdas (e.g. pass
`{ offset }` rather than `offset`) for high-frequency values like scroll/drag.
- **Unstable lambdas** allocated in the body cause child recomposition — prefer
stable method references (`viewModel::onClick`) where possible.
- **Backwards writes** — writing to state you already read in the same
composition causes an infinite recomposition loop. Never do it.
- **Side effects in the body** (logging, mutation, IO) run on every recomposition
and in undefined order. Move them into effects.
- **`Modifier` ordering matters** — order changes layout and draw; it is not
commutative.
- Always supply a stable `key` in `LazyColumn`/`LazyRow` `items(list, key = {...})`
so items keep identity across data changes (analogous to a stable
`Identifiable` id).
## Side-Effect APIs
Choose the narrowest effect that fits:
| API | When |
|---|---|
| `LaunchedEffect(key)` | Run a suspend function when entering composition / when `key` changes; cancelled on leave. |
| `rememberCoroutineScope()` | Launch coroutines from **event callbacks** (button clicks), not composition. |
| `DisposableEffect(key)` | Register/unregister non-suspend resources; `onDispose { }` cleans up. |
| `SideEffect { }` | Publish Compose state to non-Compose code after every successful recomposition. |
| `produceState` | Turn a callback/Flow source into observable `State`. |
| `snapshotFlow { }` | Convert reads of snapshot state into a cold `Flow`. |
| `rememberUpdatedState` | Capture the latest value inside a long-lived effect without restarting it. |
```kotlin
// Run once on enter (key = Unit), re-run when userId changes:
LaunchedEffect(userId) {
viewModel.load(userId) // suspend; auto-cancelled on leave/key change
}
// Observe a lifecycle/listener and clean it up:
DisposableEffect(lifecycleOwner) {
val observer = LifecycleEventObserver { _, e -> handle(e) }
lifecycleOwner.lifecycle.addObserver(observer)
onDispose { lifecycleOwner.lifecycle.removeObserver(observer) }
}
// React to snapshot state as a flow:
LaunchedEffect(listState) {
snapshotFlow { listState.firstVisibleItemIndex }
.distinctUntilChanged()
.collect { analytics.logScroll(it) }
}
```
Rules:
- The `key` of `LaunchedEffect`/`DisposableEffect` controls restart. Use a stable,
meaningful key — never `Unit` if the work depends on a changing input.
- Don't launch coroutines in the composable body; use `LaunchedEffect` (tied to
composition) or `rememberCoroutineScope` (tied to events).
- Use `rememberUpdatedState` to read the freshest callback inside a
`LaunchedEffect(Unit)` that should not restart.
## Collecting Flows
Always collect with lifecycle awareness so collection stops in the background:
```kotlin
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
```
`collectAsStateWithLifecycle()` (from `lifecycle-runtime-compose`) is preferred
over `collectAsState()` on Android — it stops collecting when the lifecycle drops
below `STARTED`, avoiding wasted work and leaks.
## Stability and Performance
- The compiler skips recomposition of a composable when all its parameters are
**stable** and unchanged. Prefer stable types: immutable `data class`es,
primitives, and `@Immutable`/`@Stable`-annotated types.
- `List<T>` is treated as unstable. Prefer `kotlinx.collections.immutable`
(`ImmutableList`/`PersistentList`) for list parameters, or wrap in a `@Immutable`
holder.
- The **Compose Compiler reports / strong-skipping** help find unstable params.
Strong skipping (default in recent compilers) skips even some unstable params by
comparing instances, but stable types remain the reliable path.
- Use `LazyColumn`/`LazyRow`/`LazyVerticalGrid` for large/scrolling collections;
never render large lists with a `Column` inside a `verticalScroll`.
- Defer high-frequency state reads (scroll offset, drag) into draw/layout lambdas
to avoid recomposing on every frame.
- Avoid `Modifier.composed { }` for new code; prefer `Modifier.Node` based
modifiers for custom modifiers.
## Material 3
Use `androidx.compose.material3` (Material 3 / Material You) for new UIs.
```kotlin
@Composable
fun AppTheme(content: @Composable () -> Unit) {
val dark = isSystemInDarkTheme()
val colors = if (supportsDynamicColor() && dark) {
dynamicDarkColorScheme(LocalContext.current) // Material You, Android 12+
} else if (dark) darkColorScheme() else lightColorScheme()
MaterialTheme(colorScheme = colors, typography = AppTypography, content = content)
}
```
- Theme through `MaterialTheme` (`colorScheme`, `typography`, `shapes`); read
values via `MaterialTheme.colorScheme.*` rather than hardcoding colors.
- Support **dynamic color** on Android 12+ via `dynamicLightColorScheme` /
`dynamicDarkColorScheme` where the product wants Material You.
- Use Material 3 components (`Scaffold`, `TopAppBar`, `NavigationBar`,
`FilledTonalButton`, etc.) and honor `WindowInsets` / edge-to-edge.
- Respect the platform: this is Material, not iOS — don't port HIG idioms wholesale.
## ViewModel Integration
- Obtain the screen `ViewModel` with `viewModel()` (or Hilt's `hiltViewModel()`).
- Expose a single immutable UI-state object as `StateFlow`; collect with
`collectAsStateWithLifecycle()`.
- Pass state down and events up (method references to the `ViewModel`). Keep
composables free of business logic.
- Use Compose Navigation (`NavHost`/`NavController`) or Navigation 3 if the
project has adopted it; hoist the `NavController` to the app root.
## Common Mistakes
1. **Side effects in the composable body** — IO, mutation, logging. Use effects.
2. **`collectAsState()` instead of `collectAsStateWithLifecycle()`** on Android.
3. **No `key` in lazy list `items`** — breaks item identity and animations.
4. **`derivedStateOf` for a 1:1 transform** — unnecessary; just compute it.
5. **`remember` where `rememberSaveable` is needed** — state lost on rotation.
6. **Launching coroutines in the body** — use `LaunchedEffect`/event scope.
7. **`LaunchedEffect(Unit)` that depends on a changing value** — stale captures;
key it properly or use `rememberUpdatedState`.
8. **Unstable parameters** (`List`, lambdas allocated inline) defeating skipping.
9. **Reading high-frequency state too high** in the tree — recomposes too much.
10. **Backwards writes** — writing state already read this composition (infinite
loop).
11. **Big scrollable `Column`** instead of `LazyColumn`.
12. **Hoisting nothing** — monolithic stateful composables that can't be tested
or previewed.
## Review Checklist
- [ ] Composables are side-effect free; effects use the proper effect API
- [ ] State hoisted appropriately (stateless leaf composables, state in ViewModel)
- [ ] `rememberSaveable` used for state that must survive config changes
- [ ] `derivedStateOf` used only for derived state that changes less than inputs
- [ ] Flows collected with `collectAsStateWithLifecycle()`
- [ ] `LaunchedEffect`/`DisposableEffect` keyed correctly; resources disposed
- [ ] Coroutines launched from events via `rememberCoroutineScope`, not body
- [ ] Lazy lists use stable `key`s
- [ ] Parameters are stable (immutable data classes / ImmutableList)
- [ ] Material 3 theming via `MaterialTheme`; dynamic color where wanted
- [ ] No backwards writes; no IO/mutation in composition
- [ ] High-frequency reads deferred (lambdas) to limit recomposition
Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.
No comments yet. Be the first to comment!