Jetpack Compose Expert
Turns Claude into a senior Android engineer shipping idiomatic Kotlin 2.x / Compose BOM 2026 code - unidirectional data flow, stable recomposition, lifecycle-correct state collection, and verified builds, not the 2021-era LiveData/collectAsState patterns that dominate training data.
When to Use This Skill
- Building screens or components with Compose + Material 3 in an existing Android app
- Fixing recomposition storms, unstable parameters, or laggy lazy lists
- Wiring ViewModel +
StateFlow state exposed to composables the lifecycle-correct way
- Adding Navigation Compose routes (type-safe,
@Serializable route classes)
- Setting up or extending Hilt DI and Room + Flow data layers
- Handling side effects:
LaunchedEffect, DisposableEffect, rememberUpdatedState, snapshotFlow
- Writing Compose UI tests (
composeTestRule, semantics) and Robolectric-hosted tests
- Edge-to-edge/insets work and performance passes (baseline profiles,
derivedStateOf)
Core Workflow
- Analyze - Read
gradle/libs.versions.toml and module build.gradle.kts before writing anything: confirm the Compose BOM version, that the org.jetbrains.kotlin.plugin.compose Gradle plugin is applied (required with Kotlin 2.x - composeOptions.kotlinCompilerExtensionVersion is gone), and which of Hilt/Room/Navigation/serialization are present. Skim the existing ui/ package for the project's screen/state/ViewModel conventions and match them.
- Implement - Write the change with unidirectional data flow: stateless composables that take state down and send events up, state hoisted to the lowest common owner, screen state in a
@HiltViewModel exposing a single StateFlow<UiState> via stateIn(viewModelScope, WhileSubscribed(5_000), initial). Collect in UI only with collectAsStateWithLifecycle(). Use Material 3 components, type-safe navigation routes, and immutable UI models.
- Verify lint/compile - Run
./gradlew lint (plus ./gradlew :app:compileDebugKotlin if lint is configured lightly); fix all reported issues and re-run until clean before proceeding. Treat Compose lint checks (ComposableNaming, RememberReturnType, FrequentlyChangingValue, coroutine-in-composition errors) as real defects, not noise.
- Test - Write or update unit tests for ViewModel/state logic (coroutine
runTest + Turbine for Flows) and Compose UI tests for the changed UI, then run ./gradlew test; fix all failures and re-run until clean. For instrumented UI tests, run ./gradlew connectedAndroidTest on a device/emulator; fix all reported issues and re-run until clean.
- Prove it works - Install and launch the app (
./gradlew installDebug, then start via adb shell am start), exercise the changed flow, and watch adb logcat for crashes and Choreographer/StrictMode warnings. For performance work, confirm with Layout Inspector recomposition counts or a Macrobenchmark that recompositions/frame times actually dropped.
Reference Guide
Load detailed guidance only when the task needs it:
| Topic |
Reference |
Load When |
State hoisting, stability, strong skipping, @Stable/@Immutable, immutable collections, derivedStateOf, lazy list performance |
references/state-recomposition.md |
Any recomposition/performance question, unstable-parameter warnings, jank in lists, or designing where state lives |
ViewModel + StateFlow, stateIn, collectAsStateWithLifecycle, Hilt setup, Room + Flow, coroutine scoping |
references/architecture-viewmodel.md |
Creating/refactoring a screen's state layer, wiring DI, adding a Room-backed feature, or viewModelScope vs rememberCoroutineScope decisions |
Navigation Compose: @Serializable routes, toRoute(), nested graphs, bottom bar back-stack behavior, deep links |
references/navigation.md |
Adding or restructuring routes, passing arguments, tab navigation state loss, or deep-link work |
Side effects: LaunchedEffect keys, rememberUpdatedState, DisposableEffect, snapshotFlow, one-shot events |
references/side-effects.md |
Any effect API usage, "effect restarts too often / never restarts" bugs, or bridging snapshot state to Flows |
Testing: composeTestRule, semantics/testTag, synchronization, Robolectric Compose tests, Turbine ViewModel tests |
references/testing.md |
Writing or fixing any Compose or ViewModel test, flaky waits, or choosing JVM vs device-hosted tests |
Key Patterns
Screen wiring - lifecycle-aware collection, stateless content composable:
@Composable
fun OrdersScreen(
onOrderClick: (String) -> Unit,
viewModel: OrdersViewModel = hiltViewModel(),
) {
val state by viewModel.uiState.collectAsStateWithLifecycle() // NOT collectAsState()
OrdersContent(state = state,
}
@Composable
private fun OrdersContent( // stateless: previewable and testable
state: OrdersUiState,
onOrderClick: (String) -> Unit,
onRetry: () -> Unit,
) { /* Material 3 UI driven only by `state` */ }
Type-safe navigation (Navigation 2.8+, kotlinx.serialization - no string routes):
@Serializable data object OrdersRoute
@Serializable data class OrderDetailRoute(val orderId: String)
NavHost(navController, startDestination = OrdersRoute) {
composable<OrdersRoute> {
OrdersScreen(onOrderClick = { id -> navController.navigate(OrderDetailRoute(id)) })
}
composable<OrderDetailRoute> { backStackEntry ->
val route = backStackEntry.toRoute<OrderDetailRoute>()
OrderDetailScreen(orderId = route.orderId)
}
}
Stable UI state - immutable models so skipping works:
@Immutable
data class OrdersUiState(
val orders: ImmutableList<OrderUi> = persistentListOf(), // kotlinx.collections.immutable
val isLoading: Boolean = false,
val error: String? = null,
)
Effect keyed correctly, latest callback without restart:
@Composable
fun SessionTimeout(onTimeout: () -> Unit) {
val currentOnTimeout by rememberUpdatedState(onTimeout)
LaunchedEffect(Unit) { // effect must run once for the composable's lifetime
delay(SESSION_MS)
currentOnTimeout() // always calls the latest lambda
}
}
Common Mistakes
collectAsState() instead of collectAsStateWithLifecycle() - collectAsState() keeps collecting while the app is backgrounded, wasting work and keeping upstream flows (location, DB observers) alive. Always use collectAsStateWithLifecycle() from lifecycle-runtime-compose; pair it with stateIn(..., WhileSubscribed(5_000), ...) in the ViewModel so upstream stops too.
- Assuming pre-strong-skipping rules. With Kotlin 2.0.20+ the Compose compiler enables strong skipping by default: composables with unstable params still skip on
equals-same values, and lambdas are auto-remembered. Do not blanket-wrap every lambda in remember; do still fix genuinely churning values - a List rebuilt every emission fails equals and defeats skipping. Prefer ImmutableList/@Immutable UI models over sprinkling @Stable on mutable classes.
- Wrong
LaunchedEffect keys. LaunchedEffect(Unit) with a captured callback runs stale code (use rememberUpdatedState); keying on an object that changes identity every recomposition restarts the effect every frame. Key on the values whose change should cancel-and-restart the work - nothing more, nothing less.
rememberCoroutineScope for business logic. That scope dies when the composable leaves composition - a save/upload launched there is cancelled by rotation. Business work belongs in viewModelScope; rememberCoroutineScope is only for UI-lifetime calls like snackbarHostState.showSnackbar(), LazyListState.animateScrollToItem(), or drawer/sheet animations.
LazyColumn without key (or with index keys) - inserts/removals recompose every following item and break animateItem() and scroll position. Provide a stable id: items(orders, key = { it.id }) { ... }. Also never put a LazyColumn with fillMaxHeight semantics inside a scrollable Column - use one lazy container with multiple item/items blocks.
- String routes and manual argument parsing -
navigate("detail/$id") plus NavType boilerplate is the deprecated idiom. Use @Serializable route classes with composable<Route> and toRoute(); pass ids, not full objects, and reload data in the destination's ViewModel via SavedStateHandle.toRoute().
- Computing derived values without
derivedStateOf - e.g. val showButton = listState.firstVisibleItemIndex > 0 directly in composition recomposes on every scroll frame. Wrap frequently-changing reads whose result changes rarely: val showButton by remember { derivedStateOf { listState.firstVisibleItemIndex > 0 } }. Conversely, don't use derivedStateOf for plain combinations of infrequently-changing state - that's just remember(a, b).
- Ignoring edge-to-edge. Targeting SDK 35+ the app is edge-to-edge whether you opt in or not. Call
enableEdgeToEdge() in the Activity, let Material 3 Scaffold pass innerPadding to content (and actually apply it), and use WindowInsets.safeDrawing/imePadding() for custom layouts - hardcoded status-bar heights and systemUiVisibility flags are dead APIs.
1---2name: jetpack-compose-expert3description: Use when working in a native Android Jetpack Compose project - build.gradle.kts with androidx.compose BOM or org.jetbrains.kotlin.plugin.compose, *.kt files containing @Composable functions, MainActivity with setContent, or mentions of Compose, Material 3, recomposition, ViewModel, StateFlow, Hilt, Room, or Navigation Compose. Builds screens, state management, navigation, DI, and tests for Kotlin 2.x / Compose BOM 2026 apps. Invoke for adding composable screens, fixing recomposition or jank, wiring ViewModel + StateFlow collection, type-safe navigation routes, Hilt setup, Room + Flow data layers, side-effect handling, and Compose UI tests.4license: MIT5---67# Jetpack Compose Expert89Turns Claude into a senior Android engineer shipping idiomatic Kotlin 2.x / Compose BOM 2026 code - unidirectional data flow, stable recomposition, lifecycle-correct state collection, and verified builds, not the 2021-era `LiveData`/`collectAsState` patterns that dominate training data.1011## When to Use This Skill1213- Building screens or components with Compose + Material 3 in an existing Android app14- Fixing recomposition storms, unstable parameters, or laggy lazy lists15- Wiring ViewModel + `StateFlow` state exposed to composables the lifecycle-correct way16- Adding Navigation Compose routes (type-safe, `@Serializable` route classes)17- Setting up or extending Hilt DI and Room + Flow data layers18- Handling side effects: `LaunchedEffect`, `DisposableEffect`, `rememberUpdatedState`, `snapshotFlow`19- Writing Compose UI tests (`composeTestRule`, semantics) and Robolectric-hosted tests20- Edge-to-edge/insets work and performance passes (baseline profiles, `derivedStateOf`)2122## Core Workflow23241. **Analyze** - Read `gradle/libs.versions.toml` and module `build.gradle.kts` before writing anything: confirm the Compose BOM version, that the `org.jetbrains.kotlin.plugin.compose` Gradle plugin is applied (required with Kotlin 2.x - `composeOptions.kotlinCompilerExtensionVersion` is gone), and which of Hilt/Room/Navigation/serialization are present. Skim the existing `ui/` package for the project's screen/state/ViewModel conventions and match them.252. **Implement** - Write the change with unidirectional data flow: stateless composables that take state down and send events up, state hoisted to the lowest common owner, screen state in a `@HiltViewModel` exposing a single `StateFlow<UiState>` via `stateIn(viewModelScope, WhileSubscribed(5_000), initial)`. Collect in UI only with `collectAsStateWithLifecycle()`. Use Material 3 components, type-safe navigation routes, and immutable UI models.263. **Verify lint/compile** - Run `./gradlew lint` (plus `./gradlew :app:compileDebugKotlin` if lint is configured lightly); fix all reported issues and re-run until clean before proceeding. Treat Compose lint checks (`ComposableNaming`, `RememberReturnType`, `FrequentlyChangingValue`, coroutine-in-composition errors) as real defects, not noise.274. **Test** - Write or update unit tests for ViewModel/state logic (coroutine `runTest` + Turbine for Flows) and Compose UI tests for the changed UI, then run `./gradlew test`; fix all failures and re-run until clean. For instrumented UI tests, run `./gradlew connectedAndroidTest` on a device/emulator; fix all reported issues and re-run until clean.285. **Prove it works** - Install and launch the app (`./gradlew installDebug`, then start via `adb shell am start`), exercise the changed flow, and watch `adb logcat` for crashes and `Choreographer`/StrictMode warnings. For performance work, confirm with Layout Inspector recomposition counts or a Macrobenchmark that recompositions/frame times actually dropped.2930## Reference Guide3132Load detailed guidance only when the task needs it:3334| Topic | Reference | Load When |35|-------|-----------|-----------|36| State hoisting, stability, strong skipping, `@Stable`/`@Immutable`, immutable collections, `derivedStateOf`, lazy list performance | `references/state-recomposition.md` | Any recomposition/performance question, unstable-parameter warnings, jank in lists, or designing where state lives |37| ViewModel + StateFlow, `stateIn`, `collectAsStateWithLifecycle`, Hilt setup, Room + Flow, coroutine scoping | `references/architecture-viewmodel.md` | Creating/refactoring a screen's state layer, wiring DI, adding a Room-backed feature, or viewModelScope vs rememberCoroutineScope decisions |38| Navigation Compose: `@Serializable` routes, `toRoute()`, nested graphs, bottom bar back-stack behavior, deep links | `references/navigation.md` | Adding or restructuring routes, passing arguments, tab navigation state loss, or deep-link work |39| Side effects: `LaunchedEffect` keys, `rememberUpdatedState`, `DisposableEffect`, `snapshotFlow`, one-shot events | `references/side-effects.md` | Any effect API usage, "effect restarts too often / never restarts" bugs, or bridging snapshot state to Flows |40| Testing: `composeTestRule`, semantics/testTag, synchronization, Robolectric Compose tests, Turbine ViewModel tests | `references/testing.md` | Writing or fixing any Compose or ViewModel test, flaky waits, or choosing JVM vs device-hosted tests |4142## Key Patterns4344**Screen wiring - lifecycle-aware collection, stateless content composable:**4546```kotlin47@Composable48fun OrdersScreen(49 onOrderClick: (String) -> Unit,50 viewModel: OrdersViewModel = hiltViewModel(),51) {52 val state by viewModel.uiState.collectAsStateWithLifecycle() // NOT collectAsState()53 OrdersContent(state = state, onOrderClick = onOrderClick, onRetry = viewModel::retry)54}5556@Composable57private fun OrdersContent( // stateless: previewable and testable58 state: OrdersUiState,59 onOrderClick: (String) -> Unit,60 onRetry: () -> Unit,61) { /* Material 3 UI driven only by `state` */ }62```6364**Type-safe navigation (Navigation 2.8+, kotlinx.serialization - no string routes):**6566```kotlin67@Serializable data object OrdersRoute68@Serializable data class OrderDetailRoute(val orderId: String)6970NavHost(navController, startDestination = OrdersRoute) {71 composable<OrdersRoute> {72 OrdersScreen(onOrderClick = { id -> navController.navigate(OrderDetailRoute(id)) })73 }74 composable<OrderDetailRoute> { backStackEntry ->75 val route = backStackEntry.toRoute<OrderDetailRoute>()76 OrderDetailScreen(orderId = route.orderId)77 }78}79```8081**Stable UI state - immutable models so skipping works:**8283```kotlin84@Immutable85data class OrdersUiState(86 val orders: ImmutableList<OrderUi> = persistentListOf(), // kotlinx.collections.immutable87 val isLoading: Boolean = false,88 val error: String? = null,89)90```9192**Effect keyed correctly, latest callback without restart:**9394```kotlin95@Composable96fun SessionTimeout(onTimeout: () -> Unit) {97 val currentOnTimeout by rememberUpdatedState(onTimeout)98 LaunchedEffect(Unit) { // effect must run once for the composable's lifetime99 delay(SESSION_MS)100 currentOnTimeout() // always calls the latest lambda101 }102}103```104105## Common Mistakes106107- **`collectAsState()` instead of `collectAsStateWithLifecycle()`** - `collectAsState()` keeps collecting while the app is backgrounded, wasting work and keeping upstream flows (location, DB observers) alive. Always use `collectAsStateWithLifecycle()` from `lifecycle-runtime-compose`; pair it with `stateIn(..., WhileSubscribed(5_000), ...)` in the ViewModel so upstream stops too.108- **Assuming pre-strong-skipping rules.** With Kotlin 2.0.20+ the Compose compiler enables strong skipping by default: composables with unstable params still skip on `equals`-same values, and lambdas are auto-remembered. Do not blanket-wrap every lambda in `remember`; do still fix genuinely churning values - a `List` rebuilt every emission fails `equals` and defeats skipping. Prefer `ImmutableList`/`@Immutable` UI models over sprinkling `@Stable` on mutable classes.109- **Wrong `LaunchedEffect` keys.** `LaunchedEffect(Unit)` with a captured callback runs stale code (use `rememberUpdatedState`); keying on an object that changes identity every recomposition restarts the effect every frame. Key on the values whose change should cancel-and-restart the work - nothing more, nothing less.110- **`rememberCoroutineScope` for business logic.** That scope dies when the composable leaves composition - a save/upload launched there is cancelled by rotation. Business work belongs in `viewModelScope`; `rememberCoroutineScope` is only for UI-lifetime calls like `snackbarHostState.showSnackbar()`, `LazyListState.animateScrollToItem()`, or drawer/sheet animations.111- **`LazyColumn` without `key` (or with index keys)** - inserts/removals recompose every following item and break `animateItem()` and scroll position. Provide a stable id: `items(orders, key = { it.id }) { ... }`. Also never put a `LazyColumn` with `fillMaxHeight` semantics inside a scrollable `Column` - use one lazy container with multiple `item`/`items` blocks.112- **String routes and manual argument parsing** - `navigate("detail/$id")` plus `NavType` boilerplate is the deprecated idiom. Use `@Serializable` route classes with `composable<Route>` and `toRoute()`; pass ids, not full objects, and reload data in the destination's ViewModel via `SavedStateHandle.toRoute()`.113- **Computing derived values without `derivedStateOf`** - e.g. `val showButton = listState.firstVisibleItemIndex > 0` directly in composition recomposes on every scroll frame. Wrap frequently-changing reads whose *result* changes rarely: `val showButton by remember { derivedStateOf { listState.firstVisibleItemIndex > 0 } }`. Conversely, don't use `derivedStateOf` for plain combinations of infrequently-changing state - that's just `remember(a, b)`.114- **Ignoring edge-to-edge.** Targeting SDK 35+ the app is edge-to-edge whether you opt in or not. Call `enableEdgeToEdge()` in the Activity, let Material 3 `Scaffold` pass `innerPadding` to content (and actually apply it), and use `WindowInsets.safeDrawing`/`imePadding()` for custom layouts - hardcoded status-bar heights and `systemUiVisibility` flags are dead APIs.