In this guide
If you learned Android the old way — XML layouts, findViewById, adapters, fragments nested in fragments — Jetpack Compose feels like a different platform. It is: instead of describing a static view hierarchy in XML and mutating it imperatively, you write Kotlin functions that describe what the UI should look like for the current state, and Compose handles updating the screen when the state changes. Less boilerplate, fewer crashes from stale view references, and UI code that reads like the UI it produces.
This guide covers the mental model (declarative UI, state, recomposition), the core building blocks, layouts, Material 3 theming, and the mistakes every Compose beginner makes.
The mental model: UI as a function of state
The entire framework reduces to one idea:
UI = f(state)
You don't "update a TextView." You hold a state variable (say, count), and your composable function declares "show the value of count." When count changes, Compose automatically re-runs the relevant functions and updates the screen. This re-running is called recomposition, and understanding it is 80% of learning Compose.
Compare the old and new approaches for a counter screen:
// Old: XML layout + imperative updates
// activity_counter.xml declares a TextView and Button...
// CounterActivity.kt:
// findViewById<TextView>(R.id.countText).text = count.toString()
// // Every state change needs a manual view update, everywhere.
// Compose: declare UI from state
@Composable
fun CounterScreen() {
var count by remember { mutableStateOf(0) }
Column {
Text(text = "Count: " + count)
Button(onClick = { count++ }) {
Text("Increment")
}
}
}
No view IDs, no manual updates. The count++ changes state; Compose recomposes and the text updates. If you're choosing between Java and Kotlin for Android first, read Kotlin vs Java for Android — Compose requires Kotlin.
Composable functions: the rules
A @Composable function follows a few strict rules:
- It's a description, not a command. It can run many times (recomposition) — so it must be free of side effects like network calls or writing to databases. Side effects go in
LaunchedEffector event handlers. - Order doesn't matter; parameters do. Compose identifies UI elements by their call position and parameters. Calling the same composable with different parameters produces different UI.
- They're fast and restartable. Compose may skip, reorder, or run composables in parallel. Never assume a composable runs exactly once.
- Naming convention: composable functions are
PascalCase(likeCounterScreen) and returnUnit.
State: remember, mutableStateOf, and hoisting
remember { mutableStateOf(0) } creates state that survives recomposition — without remember, the value would reset to 0 on every recomposition. The by delegate lets you read/write it like a plain variable.
As screens grow, hoist state upward: keep the state in the parent (or ViewModel) and pass values down plus event callbacks:
@Composable
fun CounterScreen(viewModel: CounterViewModel = viewModel()) {
val count by viewModel.count.collectAsState()
CounterContent(count = count, onIncrement = { viewModel.increment() })
}
@Composable
fun CounterContent(count: Int, onIncrement: () -> Unit) {
Column {
Text(text = "Count: " + count)
Button(onClick = onIncrement) { Text("Increment") }
}
}
CounterContent is now stateless — easier to preview, test, and reuse. State hoisting is the single habit that keeps Compose codebases maintainable, and it maps directly onto MVVM architecture (see MVVM Architecture for Android).
State holders cheat-sheet:
| Holder | Survives | Use for |
|---|---|---|
remember |
Recomposition | UI-local state (text field input, toggles) |
rememberSaveable |
Recomposition + rotation/process death | UI state the user would miss losing |
| ViewModel + StateFlow | Configuration changes, shared across screens | Business state, repository data |
Layouts: Column, Row, Box, LazyColumn
Compose layouts are just composables that arrange their children:
Column(modifier = Modifier.fillMaxSize().padding(16.dp)) {
Text("Header", style = MaterialTheme.typography.headlineMedium)
Row(horizontalArrangement = Arrangement.SpaceBetween) {
Text("Left")
Text("Right")
}
Box(modifier = Modifier.fillMaxWidth().height(120.dp)) {
Text("Overlay content", modifier = Modifier.align(Alignment.Center))
}
}
Key concepts:
- Modifier chains layout behavior: size, padding, click handling, background. Order matters —
paddingthenbackgrounddiffers frombackgroundthenpadding. - LazyColumn / LazyRow render only visible items — the replacement for RecyclerView. For a list of 1000 items, only ~15 composables exist at once.
LazyColumn {
items(sensorReadings) { reading ->
SensorRow(reading)
}
}
Material 3 theming
Wrap your app in MaterialTheme and every Text, Button, and Card picks up your color scheme and typography automatically:
MaterialTheme(
colorScheme = lightColorScheme(primary = Color(0xFF2E7D32)),
typography = Typography()
) {
CounterScreen()
}
Use MaterialTheme.colorScheme.primary and MaterialTheme.typography.* in your composables instead of hardcoded colors — then dark mode and branding changes propagate everywhere for free.
Navigation and project structure
For multi-screen student apps, the Navigation-Compose library maps routes to composables:
NavHost(navController = navController, startDestination = "home") {
composable("home") { HomeScreen(onOpen = { navController.navigate("detail") }) }
composable("detail") { DetailScreen() }
}
Keep the standard separation: composables render, ViewModels hold state, repositories fetch data. Compose doesn't replace architecture — it makes the UI layer thinner.
Common mistakes
- Side effects inside composables. Network calls or database writes directly in a composable re-run on every recomposition — use
LaunchedEffector move them to the ViewModel. - Forgetting
remember. State declared as a plainvarresets on every recomposition, producing UI that "forgets" input as you type. - Heavy work during composition. Sorting a 10,000-item list inside a composable body blocks the UI thread every recomposition — hoist it with
rememberor move it to the ViewModel. - Modifier order bugs.
clickablebeforepaddingvs after changes the touch target. When layout looks wrong, re-read the modifier chain order. - State in the wrong place. Duplicating the same state in two composables guarantees they'll disagree. Single source of truth, hoisted up.
- Preview-less development.
@Previewrenders composables in Android Studio without deploying — use it for every reusable component; it catches layout bugs in seconds.
Where to go from here
- MVVM Architecture for Android Explained — where Compose screens fit in a real app architecture.
- Kotlin vs Java for Android Development — Kotlin fundamentals Compose builds on.
- Flutter vs Native Android — if you're still choosing your cross-platform vs native path.
- How to Publish Your App on the Play Store — ship the app you built.
- More Android topics in the Android branch hub.