Jetpack Compose skill for Claude Code
Stateless screens, StateFlow state, no recomposition storms. Claude structures Compose screens as a route that collects one immutable UI state with collectAsStateWithLifecycle and a stateless screen that takes state and lambdas. It picks the right effect API with correct keys, applies Scaffold insets for edge-to-edge, and fixes slow lists with keys, deferred state reads and stable models, confirmed with Layout Inspector counts and compiler reports on a release build.
- name
jetpack-compose - size245 lines · ≈2,700 tokens
- files1
- licenseMIT
- updated
What Claude sees before loading it
This description is the only part of the skill that sits in context all the time. Claude reads it to decide when to load the full playbook below.
description: Builds Android UI with Jetpack Compose using state hoisting, a ViewModel exposing StateFlow UI state collected with collectAsStateWithLifecycle, correct side-effect APIs (LaunchedEffect, DisposableEffect, rememberUpdatedState), Material 3 theming with edge-to-edge insets, type-safe navigation, and recomposition performance work (stability, strong skipping, lazy list keys, deferred state reads, compiler reports, baseline profiles). Use when writing or reviewing composables, wiring screens to ViewModels, fixing janky lists or excessive recomposition, or migrating XML views to Compose. Try asking
- Refactor this Compose screen so the ViewModel owns the state and the UI is previewable
- The LazyColumn stutters when scrolling — find the recomposition problem
- Convert this XML settings screen to Compose with Material 3
Use it when
- Writing or reviewing composables and their ViewModels
- A Compose list or screen janks or recomposes constantly
- Choosing between remember, rememberSaveable and LaunchedEffect
- Migrating XML layouts or adopting edge-to-edge
Skip it when
- The issue is coroutines, Flow operators or Kotlin idioms broadly — use kotlin-idioms
- You need app-wide startup and memory profiling — use mobile-performance
How to check the result
Previews render every state, Compose UI tests pass, state survives rotation and process death, and a release build shows no recomposition storms in Layout Inspector.
The common mistake it prevents
Judging Compose performance in a debug build, which is far slower than release with R8 and sends you optimizing problems that do not exist.
A good Compose screen is a stateless composable driven by one immutable UI state object from a ViewModel, emits events up through lambdas, keeps side effects in the right effect API with correct keys, reads fast-changing state as late as possible, and is measured in a release build before anyone calls it slow.
Before changing anything
./gradlew :app:dependencies --configuration releaseRuntimeClasspath | grep -E "compose-bom|material3|navigation|lifecycle" | head
grep -rn "org.jetbrains.kotlin.plugin.compose\|kotlinCompilerExtensionVersion" --include=*.kts --include=*.gradle . | head
- Kotlin 2.x uses the Compose compiler Gradle plugin (
org.jetbrains.kotlin.plugin.compose); strong skipping is on by default from Kotlin 2.0.20. Older setups pinkotlinCompilerExtensionVersion. - Use the Compose BOM so all Compose artifacts share compatible versions; do not pin individual Compose library versions alongside it.
- Check
targetSdk: 35+ enforces edge-to-edge, which changes inset handling.
Workflow
- [ ] 1. Define the screen's UiState (immutable) and events
- [ ] 2. ViewModel owns state as StateFlow; exposes functions for events
- [ ] 3. Route composable collects state; screen composable is stateless
- [ ] 4. Hoist state to the lowest common owner; remember/rememberSaveable for UI-only state
- [ ] 5. Side effects with correct effect API and keys
- [ ] 6. Material 3 theme, Scaffold insets, previews for each state
- [ ] 7. Lists: keys, contentType, stable item models
- [ ] 8. Test with compose UI tests; measure performance in release
ViewModel and UI state
data class OrdersUiState(
val isLoading: Boolean = false,
val orders: List<OrderUi> = emptyList(),
val query: String = "",
val errorMessage: String? = null,
)
class OrdersViewModel(private val repo: OrdersRepository, private val saved: SavedStateHandle) : ViewModel() {
private val _state = MutableStateFlow(OrdersUiState(query = saved["query"] ?: ""))
val state: StateFlow<OrdersUiState> = _state.asStateFlow()
init { refresh() }
fun onQueryChange(q: String) {
saved["query"] = q
_state.update { it.copy(query = q) }
refresh()
}
private var loadJob: Job? = null
fun refresh() {
loadJob?.cancel()
loadJob = viewModelScope.launch {
delay(300) // debounce
_state.update { it.copy(isLoading = true, errorMessage = null) }
runCatching { repo.orders(_state.value.query) }
.onSuccess { list -> _state.update { it.copy(isLoading = false, orders = list.map(Order::toUi)) } }
.onFailure { e -> if (e is CancellationException) throw e
_state.update { it.copy(isLoading = false, errorMessage = e.message) } }
}
}
}
@Composable
fun OrdersRoute(onOpenOrder: (String) -> Unit, viewModel: OrdersViewModel = hiltViewModel()) {
val state by viewModel.state.collectAsStateWithLifecycle()
OrdersScreen(state = state, onQueryChange = viewModel::onQueryChange, onRetry = viewModel::refresh, onOpenOrder = onOpenOrder)
}
@Composable
fun OrdersScreen(
state: OrdersUiState,
onQueryChange: (String) -> Unit,
onRetry: () -> Unit,
onOpenOrder: (String) -> Unit,
modifier: Modifier = Modifier,
) { /* pure UI: no ViewModel, no repository */ }
Rules:
- Collect with
collectAsStateWithLifecycle()(lifecycle-runtime-compose), notcollectAsState(), so collection stops in the background. - Screen composables take state + lambdas and a
modifier: Modifier = Modifieras the first optional parameter; this makes them previewable and testable. - Never pass a ViewModel down to child composables; pass the data and callbacks they need.
- Re-throw
CancellationExceptionfromrunCatching/catchblocks. - Model one-off outcomes (navigate after save, show snackbar) as state the UI consumes and then acknowledges, or as a callback from the route — not as a fire-and-forget event that can be lost on configuration change.
State in composables
| Need | API |
|---|---|
| UI-only state lost on recreation is fine (dropdown expanded) | remember { mutableStateOf(false) } |
| Survive rotation/process death (text input, tab index) | rememberSaveable { mutableStateOf("") } |
| Primitive state without boxing | mutableIntStateOf, mutableFloatStateOf |
| Value computed from state that changes more often than the result | remember { derivedStateOf { listState.firstVisibleItemIndex > 0 } } |
| Expensive computation keyed on inputs | remember(input) { compute(input) } |
| State owned by a reusable component | State holder class + rememberXState() factory |
Side effects
| Need | API | Key rule |
|---|---|---|
| Run suspend work when entering composition or when a key changes | LaunchedEffect(key) | Keys = every value the block depends on; Unit only for truly once |
| Register/unregister listeners, observers | DisposableEffect(key) { ...; onDispose { } } | Always clean up in onDispose |
| Launch from a click handler | rememberCoroutineScope() | Do not launch in composition |
| Use latest lambda inside a long-running effect | rememberUpdatedState(onTimeout) | Avoid restarting the effect |
| Convert Compose state to Flow | snapshotFlow { listState.firstVisibleItemIndex } | Inside LaunchedEffect |
| Publish non-Compose state on every successful recomposition | SideEffect { } | Rarely needed |
Never call suspend functions, start network requests, or write to state directly in the composable body.
Theming and layout
@Composable
fun AppTheme(darkTheme: Boolean = isSystemInDarkTheme(), dynamicColor: Boolean = true, content: @Composable () -> Unit) {
val context = LocalContext.current
val colorScheme = when {
dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S ->
if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
darkTheme -> DarkColors
else -> LightColors
}
MaterialTheme(colorScheme = colorScheme, typography = AppTypography, content = content)
}
- Call
enableEdgeToEdge()in the Activity; useScaffoldand apply itsinnerPaddingto content, or consumeWindowInsets.safeDrawingexplicitly. ForgettinginnerPaddingputs content under the top bar. - Use
MaterialTheme.colorSchemeandMaterialTheme.typographyroles, never hardcoded colors orspvalues scattered through screens. - Modifier order matters:
Modifier.padding(16.dp).clickable {}excludes the padding from the touch area;clickablefirst includes it. Touch targets should be at least 48dp (Modifier.minimumInteractiveComponentSize()).
Navigation (type-safe Navigation Compose)
@Serializable object OrdersList
@Serializable data class OrderDetail(val id: String)
NavHost(navController, startDestination = OrdersList) {
composable<OrdersList> { OrdersRoute(onOpenOrder = { navController.navigate(OrderDetail(it)) }) }
composable<OrderDetail> { entry -> OrderDetailRoute(id = entry.toRoute<OrderDetail>().id) }
}
Pass IDs, not objects, between destinations; load the object in the destination’s
ViewModel (SavedStateHandle.toRoute<OrderDetail>()). Navigation 3 models the back stack
as plain state; follow whichever library the project already uses.
Recomposition performance
- Measure first in a release build with R8 (debug builds are much slower and misleading).
- Layout Inspector → show recomposition counts and skips; find composables recomposing on every frame or every keystroke.
- Fix in this order:
| Cause | Fix |
|---|---|
| Lazy list without keys; items recompose/reorder wrongly | items(orders, key = { it.id }, contentType = { "order" }) |
| Fast-changing state read high in the tree (scroll offset, animation) | Read it in the lowest composable, or defer via lambda modifiers: Modifier.offset { IntOffset(0, offset()) }, graphicsLayer { alpha = a() }, drawBehind {} |
| Derived boolean recomputed every frame | derivedStateOf |
Unstable parameter types (classes from non-Compose modules, List from Java) | Map to UI models; kotlinx.collections.immutable lists; @Immutable/@Stable only when true |
| Backwards write (writing state already read in this composition) | Move the write to an event or effect |
| Heavy work in composition (sorting, formatting) | Move to the ViewModel or remember(key) |
- Compiler reports show which composables are skippable and which params are unstable:
// app/build.gradle.kts
composeCompiler {
reportsDestination = layout.buildDirectory.dir("compose_compiler")
metricsDestination = layout.buildDirectory.dir("compose_compiler")
}
- Add a Baseline Profile (Macrobenchmark +
androidx.baselineprofileplugin) for startup and scroll paths; it typically removes first-run jank (seemobile-performance).
Previews and tests
@PreviewLightDark
@PreviewFontScale
@Composable
private fun OrdersScreenPreview(@PreviewParameter(OrdersStateProvider::class) state: OrdersUiState) {
AppTheme { OrdersScreen(state, onQueryChange = {}, onRetry = {}, onOpenOrder = {}) }
}
@get:Rule val compose = createComposeRule()
@Test fun showsErrorAndRetries() {
var retried = false
compose.setContent { OrdersScreen(OrdersUiState(errorMessage = "Offline"), {}, { retried = true }, {}) }
compose.onNodeWithText("Offline").assertIsDisplayed()
compose.onNodeWithText("Retry").performClick()
assertTrue(retried)
}
Gotchas
rememberwithout keys keeps a stale value when its inputs change; add the inputs as keys.LaunchedEffect(Unit)that reads a parameter uses the first value forever; key it or userememberUpdatedState.mutableStateListOfexposed from a ViewModel lets the UI mutate it; expose immutableListinStateFlowinstead.- Nested scrolling containers in the same direction (LazyColumn inside a verticalScroll Column) crash or measure infinitely; use one lazy list with multiple item types.
AndroidViewinterop: release resources inonRelease/DisposableEffect.
Verification
- Previews render for loading, empty, error, populated, dark, and 200% font scale.
- Compose UI tests cover the screen’s states and callbacks.
- Rotate and trigger process death (Developer options → “Don’t keep activities”): input and scroll position survive where expected.
- Release build on a mid-range device: scroll the main list with the Layout Inspector or
adb shell dumpsys gfxinfo <package>and confirm no recomposition storms or janky frames.
About this skill
Written and maintained by the Claude Skills editors to the same editorial standard as every skill in the directory: a selective trigger, a real workflow, known pitfalls and a verification step. First published , last updated . MIT licensed.