← All tech

Mobile / UI · default pick

Jetpack Compose

Modern declarative Android UI in Kotlin, driven by state and recomposition.

  • Declarative Android UI with far less boilerplate than XML views
  • State-driven screens where UI is a pure function of state
  • High-performance scrolling lists with LazyColumn / LazyRow
  • Material 3 theming and components out of the box
  • Live previews that render composables without an emulator
Use it when

Reach for Compose when you're building an Android app and want the UI to follow your state automatically — describe what the screen should look like for a given state, and the framework redraws only what changed.

Reach for something else when

Skip Compose when you must ship to iOS from the same UI code (use Flutter or Compose Multiplatform), when a mature app's View-based UI works fine and a rewrite isn't justified, or when you're not targeting Android/Kotlin at all.

Official docs ↗


Jetpack Compose is the default Android UI of this curriculum because it replaces XML layouts and manual findViewById wiring with plain Kotlin functions that describe the screen for a given state. This track moves from your first @Composable to hoisted state, Material 3, lazy lists, a ViewModel + StateFlow pipeline, navigation, and recomposition performance — tagged by level so you can read only as deep as you need.

Create a Compose project and run it

Beginner

In Android Studio, create a new project from the “Empty Activity” (Compose) template, then run it on an emulator.

Why the Compose template, and what it gives you

The “Empty Activity” template in current Android Studio is already a Compose project: it sets up the Compose compiler plugin, the Material 3 dependency, and a MainActivity whose onCreate calls setContent { } instead of setContentView(R.layout.…). There is no XML layout file — the UI is Kotlin. You don’t wire up a build by hand for this track; the template does it so you can focus on composables.

Verify the toolchain
Run these in your terminal / editor
# Confirm Android Studio's bundled JDK / Gradle wrapper are present
./gradlew --version

# Build and install the debug app on a running emulator/device
./gradlew installDebug

Write your first @Composable

Beginner

Annotate a Kotlin function with @Composable and emit UI by calling other composables like Text.

What @Composable actually means

A @Composable function describes a piece of UI; it doesn’t return a View, it emits UI into the composition by calling other composables (Text, Column, Button). Composable functions are normal Kotlin — they take parameters and can call each other — but they may only be called from other composables. By convention a composable is named in PascalCase like a type (Greeting, not greeting) and returns Unit.

A minimal composable
Run these in your terminal / editor
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable

@Composable
fun Greeting(name: String) {
    Text(text = "Hello, $name")
}
Agent prompt — paste into an agent with repo access
For Claude Code / Cursor / an agent that can read & edit this repo.
Role: Senior Android engineer working in this Jetpack Compose repo.
Context: A fresh "Empty Activity" Compose project (Material 3, Compose compiler set up). MainActivity uses setContent { }.
Task: Add a Greeting(name: String) composable in MainActivity.kt and show it for name = "Aurora" from setContent.
Requirements:
- Greeting is a top-level @Composable that emits a single Text.
- Wrap the call in the app's MaterialTheme so it picks up Material 3 styling.
- No XML layout; UI lives entirely in setContent.
Tests / acceptance:
- `./gradlew assembleDebug` succeeds with no errors.
- Running the app shows "Hello, Aurora" on screen.
Output: a unified diff plus a one-paragraph summary of what setContent does.

Preview composables without an emulator

Beginner

Add a function annotated with @Preview that calls your composable, then open the design pane in Android Studio.

Why previews change the loop

@Preview renders a composable directly inside Android Studio — no build-install-launch cycle, no emulator boot. The previewed function takes no parameters, so you write a tiny wrapper that supplies sample data and calls the real composable. You can declare several previews (light/dark, different font scales, different states) side by side. Previews are the fastest feedback loop in Compose; lean on them constantly.

A preview wrapper
Run these in your terminal / editor
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.runtime.Composable

@Preview(showBackground = true)
@Composable
fun GreetingPreview() {
    Greeting(name = "Aurora")
}

Lay out UI with Column, Row, and Modifier

Beginner

Arrange composables with Column and Row, and adjust size, padding, and spacing through the modifier parameter.

The Modifier chain is order-sensitive

Column stacks children vertically, Row horizontally, Box overlaps them. Almost every composable accepts a modifier: Modifier that decorates it — Modifier.padding(16.dp).fillMaxWidth() reads left-to-right and order matters: padding before a background paints the padding outside the background; after, inside it. Pass a modifier parameter through your own composables so callers can position them — that’s the idiomatic Compose API shape.

A simple laid-out card
Run these in your terminal / editor
import androidx.compose.foundation.layout.*
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp

@Composable
fun ProductRow(name: String, price: String, modifier: Modifier = Modifier) {
    Row(
        modifier = modifier.fillMaxWidth().padding(16.dp),
        horizontalArrangement = Arrangement.SpaceBetween,
    ) {
        Text(text = name)
        Text(text = price)
    }
}

Hold UI state with remember and mutableStateOf

Intermediate

Store changing UI state in remember { mutableStateOf(...) }, and update it to trigger a redraw.

State, recomposition, and why remember matters

Compose redraws by recomposition: when a state value a composable reads changes, Compose re-invokes that composable (and only what depends on it). mutableStateOf creates an observable state holder; remember keeps it alive across recompositions so it isn’t reset every frame. Use by with the getValue/setValue delegates to read/write it like a normal variable. For state that must survive configuration changes (rotation), use rememberSaveable instead of remember.

A counter with local state
Run these in your terminal / editor
import androidx.compose.material3.Button
import androidx.compose.material3.Text
import androidx.compose.runtime.*

@Composable
fun Counter() {
    var count by remember { mutableStateOf(0) }
    Button(onClick = { count++ }) {
        Text(text = "Clicked $count times")
    }
}

Hoist state for unidirectional data flow

Intermediate

Move state out of a composable: pass the value down as a parameter and pass events up as lambdas.

Stateless composables and the UDF loop

State hoisting moves a composable’s state to its caller, making the composable stateless: it receives the current value and an onValueChange: (T) -> Unit callback. This is unidirectional data flow — state flows down, events flow up. Stateless composables are easier to preview, reuse, and test, and they keep a single source of truth for each piece of state. Hoist state to the lowest common ancestor that needs to read or change it.

A hoisted, stateless input
Run these in your terminal / editor
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable

@Composable
fun SearchField(
    query: String,
    onQueryChange: (String) -> Unit,
) {
    OutlinedTextField(
        value = query,
        onValueChange = onQueryChange,
        label = { Text("Search products") },
    )
}
Chat prompt — paste into a chat to get the code
For a plain chat. It returns complete code; you paste it in yourself.
Role: Jetpack Compose teacher. The reader has no repo access — return complete, compilable code.
Task: Show a stateful "controller" composable that owns query state with rememberSaveable, and a stateless SearchField(query, onQueryChange) it drives, demonstrating state hoisting and unidirectional data flow.
Requirements:
- The stateless composable takes only value + an onValueChange lambda; it owns no state.
- The stateful parent uses rememberSaveable so the query survives rotation.
- Use Material 3 OutlinedTextField; include all imports.
Tests / acceptance (describe, since no repo):
- Typing in the field updates the displayed query.
- Rotating the device preserves the typed text.
Output: the complete Kotlin file, no commentary.

Render long lists with LazyColumn

Intermediate

Replace a scrolling Column with LazyColumn and emit rows inside its items(...) block.

Why LazyColumn instead of a scrollable Column

A Column with verticalScroll composes every child up front — fine for a handful, ruinous for thousands. LazyColumn (and LazyRow) only composes and lays out the items currently visible, recycling as you scroll, the way RecyclerView did for the View system. Provide a stable key per item so Compose can track items across data changes and animate/reorder correctly. Use LazyColumn for any list whose length you don’t control.

A keyed lazy list
Run these in your terminal / editor
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.runtime.Composable

@Composable
fun ProductList(products: List<Product>) {
    LazyColumn {
        items(products, key = { it.id }) { product ->
            ProductRow(name = product.name, price = product.price)
        }
    }
}
Agent prompt — paste into an agent with repo access
For Claude Code / Cursor / an agent that can read & edit this repo.
Role: Senior Android engineer in this Jetpack Compose repo.
Context: A Product(id: String, name: String, price: String) data class exists, plus a stateless ProductRow(name, price, modifier) composable.
Task: Add a ProductList(products: List<Product>) composable backed by LazyColumn, with a @Preview that feeds it sample data.
Requirements:
- Use LazyColumn with items(products, key = { it.id }) — a stable key per row.
- Add contentPadding and an item spacing so rows don't touch.
- Provide a @Preview that supplies 3+ sample products.
Tests / acceptance:
- `./gradlew assembleDebug` succeeds.
- The Android Studio preview renders the sample rows in a scrollable list.
Output: a unified diff plus a one-paragraph note on why key matters.

Theme the app with Material 3

Intermediate

Wrap your UI in MaterialTheme with a Material 3 colorScheme, and read colors and type from the theme.

Material 3 and dynamic color

Material 3 (androidx.compose.material3) supplies themed components — Button, Card, Scaffold, TopAppBar — that read their styling from a MaterialTheme you wrap around your app. Pull values from MaterialTheme.colorScheme and MaterialTheme.typography rather than hard-coding colors, so light/dark and (on Android 12+) Material You dynamic color from the user’s wallpaper work for free via dynamicLightColorScheme / dynamicDarkColorScheme. Scaffold gives you the standard app-bar / content / FAB slots. TopAppBar is still marked ExperimentalMaterial3Api, so a composable that uses it needs @OptIn(ExperimentalMaterial3Api::class) until the API stabilises.

A themed scaffold
Run these in your terminal / editor
import androidx.compose.material3.*
import androidx.compose.runtime.Composable

@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ProductsScreen(products: List<Product>) {
    Scaffold(
        topBar = { TopAppBar(title = { Text("Aurora") }) },
    ) { padding ->
        ProductList(products = products, modifier = Modifier.padding(padding))
    }
}

Drive UI from a ViewModel and StateFlow

Advanced

Expose screen state from a ViewModel as a StateFlow, and collect it with collectAsStateWithLifecycle().

Where state lives at scale, and lifecycle-safe collection

Local remember is fine for a widget; screen-level state belongs in a ViewModel, which survives configuration changes and holds your business logic. Model the screen as an immutable UI-state data class, expose it as a StateFlow<UiState> (commonly a private MutableStateFlow behind a public read-only StateFlow), and collect it in the composable with collectAsStateWithLifecycle() from androidx.lifecycle:lifecycle-runtime-compose. That collector stops gathering when the UI isn’t started, avoiding wasted work and leaks — preferred over the lifecycle-agnostic collectAsState(). The composable stays a stateless render of the latest UiState; events call ViewModel methods.

ViewModel exposing a StateFlow
Run these in your terminal / editor
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch

data class ProductsUiState(
    val products: List<Product> = emptyList(),
    val isLoading: Boolean = true,
)

class ProductsViewModel(private val repo: ProductRepository) : ViewModel() {
    private val _uiState = MutableStateFlow(ProductsUiState())
    val uiState: StateFlow<ProductsUiState> = _uiState.asStateFlow()

    init {
        viewModelScope.launch {
            val items = repo.load()
            _uiState.update { it.copy(products = items, isLoading = false) }
        }
    }
}
Lifecycle-aware collection in the composable
Run these in your terminal / editor
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.compose.runtime.Composable

@Composable
fun ProductsRoute(viewModel: ProductsViewModel = viewModel()) {
    val state by viewModel.uiState.collectAsStateWithLifecycle()
    ProductsScreen(products = state.products)
}
Agent prompt — paste into an agent with repo access
For Claude Code / Cursor / an agent that can read & edit this repo.
Role: Senior Android engineer in this Jetpack Compose repo.
Context: A ProductRepository with suspend fun load(): List<Product> exists. We use androidx.lifecycle ViewModel, kotlinx.coroutines StateFlow, and lifecycle-runtime-compose.
Task: Implement ProductsViewModel exposing a StateFlow<ProductsUiState>, and a stateless ProductsScreen plus a ProductsRoute that collects it with collectAsStateWithLifecycle().
Requirements:
- Immutable ProductsUiState data class with products + isLoading.
- Private MutableStateFlow, public asStateFlow(); update state inside viewModelScope.launch.
- ProductsScreen takes the state as a parameter and owns no ViewModel reference (stays previewable).
- Use collectAsStateWithLifecycle(), not collectAsState().
Tests / acceptance:
- A JUnit test with a fake ProductRepository (returning 2 products) asserts uiState.value.products.size == 2 and isLoading == false after load — use kotlinx-coroutines-test runTest.
- `./gradlew testDebugUnitTest` passes.
Output: a unified diff plus a one-paragraph note on why collectAsStateWithLifecycle is preferred.

Keep recomposition cheap and correct

Advanced

Profile recomposition counts, give lists stable keys, and hoist or remember expensive values so composables don’t redo work each frame.

Skipping, stability, and remember(key)

Compose skips recomposing a composable when its inputs are unchanged and “stable”. Unstable inputs (e.g. a plain List or a class with var fields) defeat skipping and cause needless recompositions. Prefer immutable data and read-only collections (or mark types with @Immutable/@Stable); use the Layout Inspector’s recomposition counts to find hotspots. Cache derived work with remember(key) { … } so it recomputes only when key changes, and derivedStateOf when a value is computed from other State objects that change more often than the result. Don’t perform expensive work directly in a composable body — it runs on every recomposition.

Memoise derived work
Run these in your terminal / editor
import androidx.compose.runtime.*

@Composable
fun ProductList(products: List<Product>, query: String) {
    // Recomputes only when products or query change, not on every recomposition.
    val visible = remember(products, query) {
        products.filter { it.name.contains(query, ignoreCase = true) }
    }
    LazyColumn {
        items(visible, key = { it.id }) { ProductRow(it.name, it.price) }
    }
}
Profile recomposition counts
Run these in your terminal / editor
# In the module's build.gradle.kts, enable Compose compiler reports:
#   composeCompiler {
#       reportsDestination = layout.buildDirectory.dir("compose_compiler")
#       metricsDestination = layout.buildDirectory.dir("compose_compiler")
#   }
# then build (release for accurate results) to see which composables are skippable/restartable:
./gradlew assembleRelease
Agent prompt — paste into an agent with repo access
For Claude Code / Cursor / an agent that can read & edit this repo.
Role: Senior Android performance engineer in this Jetpack Compose repo.
Context: ProductList recomposes too often while typing in the search field; the filter runs on every keystroke and every unrelated recomposition.
Task: Make ProductList recompose efficiently: stabilize inputs, key the list, and memoise the filtered result.
Requirements:
- The filtered list is computed with remember(products, query), not inline in the composable body.
- LazyColumn items use a stable key (it.id).
- If Product or the list type is unstable, make it immutable (immutable List + val fields, or @Immutable) and explain why.
- Do not change visible behaviour; the same items render for a given query.
Tests / acceptance:
- Generate the Compose compiler report (composeCompiler { reportsDestination = ... } in build.gradle.kts) and confirm ProductList is "restartable" and "skippable".
- `./gradlew assembleDebug` succeeds.
Output: a unified diff plus before/after notes on recomposition behaviour from the compiler report.

Where to take it next

  • Build a real shopping UI on top of these patterns in Aurora Commerce, where Compose screens render the catalogue and checkout flow over a live backend.
  • Shipping the same app to iPhone too? Compare the Apple-native equivalent in SwiftUI — the model is nearly identical, the platform is not.
  • Want to share the logic beneath the UI across Android and iOS? See the Kotlin track for Kotlin Multiplatform.