Skip to main content
Technology & EngineeringAndroid Kotlin232 lines

Jetpack Compose

Jetpack Compose declarative UI toolkit for building native Android interfaces with Kotlin

Quick Summary18 lines
You are an expert in Jetpack Compose for building Android applications with Kotlin.

## Key Points

- Always pass a `Modifier` parameter to public composables and apply it to the root element.
- Use `remember` with `derivedStateOf` for expensive computations that depend on state.
- Keep composables small and focused; extract sub-composables aggressively.
- Use `key` in `LazyColumn` items to preserve state across recompositions.
- Prefer `rememberSaveable` over `remember` for state that should survive configuration changes.
- Use the `Modifier` chain to express layout, sizing, padding, and interaction — avoid nested wrappers.
- Collect `Flow` values with `collectAsStateWithLifecycle()` from the `lifecycle-runtime-compose` library.
- Performing heavy computation inside a composable without `remember` or `derivedStateOf`, causing work on every recomposition.
- Using `mutableStateListOf` or `mutableStateMapOf` incorrectly — mutations must happen on the snapshot-aware collections directly, not copies.
- Forgetting `key` in `LazyColumn`, leading to incorrect item state after list changes.
- Nesting scrollable containers (`LazyColumn` inside a `Column` with `verticalScroll`) without using `Modifier.nestedScroll` or fixed-height constraints.
- Recomposition skipping: Compose skips recomposition for stable, unchanged parameters. Passing unstable lambdas or objects defeats this optimization.
skilldb get android-kotlin-skills/Jetpack ComposeFull skill: 232 lines
Paste into your CLAUDE.md or agent config

Jetpack Compose — Android/Kotlin

You are an expert in Jetpack Compose for building Android applications with Kotlin.

Core Philosophy

Jetpack Compose replaces the imperative "mutate the UI to match the new state" model with a declarative one: describe what the UI should look like for a given state, and the framework handles the transitions. This means you stop thinking about "when the user taps this button, update that text field" and start thinking about "the screen is a function of this state object." The framework calls your composable function, diffs the output against the previous result, and updates only what changed. Your job is to get the state right; Compose handles the rest.

Recomposition is the mechanism that makes this work, and understanding it is essential. When state changes, Compose re-executes the composable functions that read that state. It can skip functions whose inputs have not changed, which is why stable and immutable types matter -- they enable the compiler to prove that recomposition can be skipped. Writing composables that perform expensive work without remember or derivedStateOf means that work runs on every recomposition, potentially dozens of times per second during animations.

State hoisting is the foundational pattern for reusable composables. A composable that manages its own state internally cannot be controlled from outside, cannot be tested without rendering, and cannot be reused in different contexts. By lifting state up to the caller and accepting it as parameters along with event callbacks, composables become stateless, reusable building blocks that can be composed in any context.

Anti-Patterns

  • Performing heavy computation inside composable functions without remember: Sorting a list, filtering a collection, or formatting data directly in a composable's body runs on every recomposition. Use remember with derivedStateOf to cache computed values and recalculate only when their dependencies change.

  • Forgetting key in LazyColumn items: Without a stable key, Compose cannot track which items moved, were added, or were removed. This leads to incorrect state being shown on the wrong items (e.g., checkboxes toggling on the wrong row) and defeats Compose's ability to efficiently animate list changes.

  • Using mutableStateOf in the wrong scope: Creating remember { mutableStateOf(...) } inside a child composable that gets recreated by its parent means the state resets whenever the parent recomposes. State must be hoisted to the scope that matches its desired lifetime.

  • Nesting scrollable containers without constraints: Placing a LazyColumn inside a Column with verticalScroll creates a conflict where both containers try to consume scroll gestures, usually resulting in a crash. Either give the inner LazyColumn a fixed height or use nestedScroll to coordinate.

  • Passing unstable lambdas that defeat recomposition skipping: Compose skips recomposing a composable when its parameters have not changed. But if you pass a lambda created inline (like onClick = { viewModel.doSomething() }) without remember, the lambda reference changes on every recomposition, defeating skip optimization. Use remember for lambdas or reference stable method references.

Overview

Jetpack Compose is Android's modern, fully declarative UI toolkit. Instead of XML layouts, you describe your UI as composable functions that emit UI elements. Compose handles rendering, recomposition, and state management automatically, enabling a reactive programming model for Android UIs.

Core Concepts

Composable Functions

Every UI element in Compose is a @Composable function. These functions describe what the UI should look like for a given state, not how to transition between states.

@Composable
fun Greeting(name: String, modifier: Modifier = Modifier) {
    Text(
        text = "Hello, $name!",
        modifier = modifier.padding(16.dp),
        style = MaterialTheme.typography.headlineMedium
    )
}

State and Recomposition

Compose re-executes composable functions when their inputs (state) change. Use remember and mutableStateOf to hold and observe state.

@Composable
fun Counter() {
    var count by remember { mutableStateOf(0) }

    Column(horizontalAlignment = Alignment.CenterHorizontally) {
        Text("Count: $count", style = MaterialTheme.typography.titleLarge)
        Spacer(modifier = Modifier.height(8.dp))
        Button(onClick = { count++ }) {
            Text("Increment")
        }
    }
}

State Hoisting

Lift state up to the caller so composables remain stateless and reusable.

@Composable
fun EmailInput(
    email: String,
    onEmailChange: (String) -> Unit,
    modifier: Modifier = Modifier
) {
    OutlinedTextField(
        value = email,
        onValueChange = onEmailChange,
        label = { Text("Email") },
        modifier = modifier.fillMaxWidth()
    )
}

@Composable
fun LoginScreen() {
    var email by remember { mutableStateOf("") }
    EmailInput(email = email, onEmailChange = { email = it })
}

Layouts

Compose provides Row, Column, Box, and LazyColumn/LazyRow for arranging elements.

@Composable
fun UserCard(user: User) {
    Card(
        modifier = Modifier
            .fillMaxWidth()
            .padding(8.dp),
        elevation = CardDefaults.cardElevation(defaultElevation = 4.dp)
    ) {
        Row(
            modifier = Modifier.padding(16.dp),
            verticalAlignment = Alignment.CenterVertically
        ) {
            AsyncImage(
                model = user.avatarUrl,
                contentDescription = "Avatar",
                modifier = Modifier
                    .size(48.dp)
                    .clip(CircleShape)
            )
            Spacer(modifier = Modifier.width(12.dp))
            Column {
                Text(user.name, style = MaterialTheme.typography.titleMedium)
                Text(user.email, style = MaterialTheme.typography.bodySmall)
            }
        }
    }
}

Lazy Lists

LazyColumn and LazyRow only compose visible items, making them efficient for long lists.

@Composable
fun UserList(users: List<User>) {
    LazyColumn(
        contentPadding = PaddingValues(16.dp),
        verticalArrangement = Arrangement.spacedBy(8.dp)
    ) {
        items(users, key = { it.id }) { user ->
            UserCard(user = user)
        }
    }
}

Implementation Patterns

Theming with Material 3

@Composable
fun AppTheme(
    darkTheme: Boolean = isSystemInDarkTheme(),
    content: @Composable () -> Unit
) {
    val colorScheme = if (darkTheme) {
        dynamicDarkColorScheme(LocalContext.current)
    } else {
        dynamicLightColorScheme(LocalContext.current)
    }

    MaterialTheme(
        colorScheme = colorScheme,
        typography = AppTypography,
        content = content
    )
}

Side Effects

Use LaunchedEffect, DisposableEffect, and SideEffect to run effects tied to the composable lifecycle.

@Composable
fun TimerScreen() {
    var elapsed by remember { mutableStateOf(0) }

    LaunchedEffect(Unit) {
        while (true) {
            delay(1000)
            elapsed++
        }
    }

    Text("Elapsed: ${elapsed}s")
}

Animations

@Composable
fun ExpandableCard(title: String, body: String) {
    var expanded by remember { mutableStateOf(false) }
    val extraPadding by animateDpAsState(
        targetValue = if (expanded) 24.dp else 0.dp,
        animationSpec = spring(dampingRatio = Spring.DampingRatioMediumBouncy)
    )

    Card(modifier = Modifier.clickable { expanded = !expanded }) {
        Column(modifier = Modifier.padding(16.dp + extraPadding)) {
            Text(title, style = MaterialTheme.typography.titleMedium)
            AnimatedVisibility(visible = expanded) {
                Text(body, modifier = Modifier.padding(top = 8.dp))
            }
        }
    }
}

Best Practices

  • Always pass a Modifier parameter to public composables and apply it to the root element.
  • Use remember with derivedStateOf for expensive computations that depend on state.
  • Keep composables small and focused; extract sub-composables aggressively.
  • Use key in LazyColumn items to preserve state across recompositions.
  • Prefer rememberSaveable over remember for state that should survive configuration changes.
  • Use the Modifier chain to express layout, sizing, padding, and interaction — avoid nested wrappers.
  • Collect Flow values with collectAsStateWithLifecycle() from the lifecycle-runtime-compose library.

Common Pitfalls

  • Performing heavy computation inside a composable without remember or derivedStateOf, causing work on every recomposition.
  • Using mutableStateListOf or mutableStateMapOf incorrectly — mutations must happen on the snapshot-aware collections directly, not copies.
  • Forgetting key in LazyColumn, leading to incorrect item state after list changes.
  • Nesting scrollable containers (LazyColumn inside a Column with verticalScroll) without using Modifier.nestedScroll or fixed-height constraints.
  • Recomposition skipping: Compose skips recomposition for stable, unchanged parameters. Passing unstable lambdas or objects defeats this optimization.
  • Using remember { mutableStateOf(...) } in the wrong scope, causing state to reset when parent recomposes.

Install this skill directly: skilldb add android-kotlin-skills

Get CLI access →