Mobile No. 338

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.

  • namejetpack-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.

~/.claude/skills/jetpack-compose/SKILL.md

--- name: jetpack-compose description: Builds Android UI with Jetpack Compose using state hoisting, a ViewModel exposing StateFlow UI state collected with… license: MIT ---

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 pin kotlinCompilerExtensionVersion.
  • 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), not collectAsState(), so collection stops in the background.
  • Screen composables take state + lambdas and a modifier: Modifier = Modifier as 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 CancellationException from runCatching/catch blocks.
  • 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

NeedAPI
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 boxingmutableIntStateOf, mutableFloatStateOf
Value computed from state that changes more often than the resultremember { derivedStateOf { listState.firstVisibleItemIndex > 0 } }
Expensive computation keyed on inputsremember(input) { compute(input) }
State owned by a reusable componentState holder class + rememberXState() factory

Side effects

NeedAPIKey rule
Run suspend work when entering composition or when a key changesLaunchedEffect(key)Keys = every value the block depends on; Unit only for truly once
Register/unregister listeners, observersDisposableEffect(key) { ...; onDispose { } }Always clean up in onDispose
Launch from a click handlerrememberCoroutineScope()Do not launch in composition
Use latest lambda inside a long-running effectrememberUpdatedState(onTimeout)Avoid restarting the effect
Convert Compose state to FlowsnapshotFlow { listState.firstVisibleItemIndex }Inside LaunchedEffect
Publish non-Compose state on every successful recompositionSideEffect { }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; use Scaffold and apply its innerPadding to content, or consume WindowInsets.safeDrawing explicitly. Forgetting innerPadding puts content under the top bar.
  • Use MaterialTheme.colorScheme and MaterialTheme.typography roles, never hardcoded colors or sp values scattered through screens.
  • Modifier order matters: Modifier.padding(16.dp).clickable {} excludes the padding from the touch area; clickable first includes it. Touch targets should be at least 48dp (Modifier.minimumInteractiveComponentSize()).
@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

  1. Measure first in a release build with R8 (debug builds are much slower and misleading).
  2. Layout Inspector → show recomposition counts and skips; find composables recomposing on every frame or every keystroke.
  3. Fix in this order:
CauseFix
Lazy list without keys; items recompose/reorder wronglyitems(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 framederivedStateOf
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)
  1. 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")
}
  1. Add a Baseline Profile (Macrobenchmark + androidx.baselineprofile plugin) for startup and scroll paths; it typically removes first-run jank (see mobile-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

  • remember without 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 use rememberUpdatedState.
  • mutableStateListOf exposed from a ViewModel lets the UI mutate it; expose immutable List in StateFlow instead.
  • Nested scrolling containers in the same direction (LazyColumn inside a verticalScroll Column) crash or measure infinitely; use one lazy list with multiple item types.
  • AndroidView interop: release resources in onRelease/DisposableEffect.

Verification

  1. Previews render for loading, empty, error, populated, dark, and 200% font scale.
  2. Compose UI tests cover the screen’s states and callbacks.
  3. Rotate and trigger process death (Developer options → “Don’t keep activities”): input and scroll position survive where expected.
  4. 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.

esc