Jetpack Compose: The 30% You Actually Need
A tiny, no-fluff intro to Compose's mental model and the handful of APIs you'll use every day.
In Compose, state controls the UI. Tell the framework how the screen should look for a certain condition and let it manage the changes. You can ship genuine features if you know how to use @Composable Column/Row/Box, Modifier, and basic state with remember/rememberSaveable.
If you're used to XML and imperative view changes, Compose will feel more like a change in how you think than a new set of tools. You construct functions that turn state into UI instead of locating views and changing them when events happen. Compose recomposes the sections of the tree that are affected when the state changes. That's the whole game. You can be surprisingly productive if you learn a few basic things.
Let's set some goals. I think you know Kotlin and can open Android Studio. By the end of this brief read, you'll know the main ideas and have a small sample that you can copy and paste to run. No detours, no architecture lecture—just the basics.
The first thing is the mental model. A composable function is marked with @Composable and doesn't return a view; instead, it sends UI into the current composition. In a way, it's like a command: "for this state, draw this." Recomposition is the process that runs again as inputs change, only changing and adding what needs to be changed. You can't choose when recomposition happens, but you can make it inexpensive by keeping your functions small and your state in the correct place.
The layouts are meant to be simple. Column, Row, and Box let you show most screens. A Column stacks children on top of each other, a Row lays them out side by side, and a Box lets them overlap or stick to the sides. The word "spacing" derives from the word "spacer" and the word "modifier," which is everywhere. You will add a Modifier to practically everything. It's how you scale, pad, clip, draw backgrounds, and attach input handlers like clickable. Order is important because modifiers make a chain. For example, layout modifiers usually come before visual ones, and input commonly comes last.
People trip in the state. Use remember { mutableStateOf(...) } for temporary UI state that shouldn't stay the same when the configuration changes, and rememberSaveable { ... } when you do want it to stay the same after the process dies or the rotation happens. To show or change state that is higher up, hoist it: provide the current value with a callback like onValueChange. This makes it clear who owns what and keeps dependencies from getting tangled.
Compose has a specific way of handling lists with LazyColumn and other tools. They only work on the things you can see, thus performance is automatically good if you give them stable keys. Give Compose a key parameter, such an ID, so it can keep track of item identity even after modifications. The most prevalent reason for jumpy scroll positions and extra recompositions is forgetting keys.
It's easy to learn how to theme. Use MaterialTheme to wrap your UI and its color scheme, fonts, and shapes. If you use MaterialTheme to talk about colors. colorScheme.primary instead of hard-coding. Dark mode and brand changes coming for free later.
Here's a little demo that brings these ideas together. It has a counter and a small list that you can add to. You can use rememberSaveable with state, layout primitives, modifiers, and a LazyColumn that has stable keys.
@Composable
fun MiniApp() {
var count by rememberSaveable { mutableStateOf(0) }
var items by rememberSaveable { mutableStateOf(listOf<Pair<Int, String>>()) }
Column(
modifier = Modifier
.fillMaxSize()
.padding(16.dp)
) {
Text("Count: $count", style = MaterialTheme.typography.headlineMedium)
Spacer(Modifier.height(8.dp))
Row {
Button(onClick = { count++ }) { Text("Increment") }
Spacer(Modifier.width(8.dp))
Button(onClick = {
val nextId = (items.lastOrNull()?.first ?: 0) + 1
items = items + (nextId to "Item #$nextId")
}) { Text("Add item") }
}
Spacer(Modifier.height(16.dp))
LazyColumn(
modifier = Modifier.weight(1f),
contentPadding = PaddingValues(vertical = 8.dp)
) {
items(
items = items,
key = { it.first } // stable key by id
) { (id, label) ->
Box(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 6.dp)
.clip(RoundedCornerShape(12.dp))
.background(MaterialTheme.colorScheme.surfaceVariant)
.padding(12.dp)
) {
Text("$label · id=$id")
}
}
}
}
}
@Preview(showBackground = true)
@Composable
fun MiniAppPreview() {
MaterialTheme { MiniApp() }
}
What makes this work well? The list and the counter are both views of the state. Every button changes the state, and Compose sees the change and only recomposes the nodes that were changed. Stable keys maintain the identification of each item the same as the list grows. Without having to do any global styling gymnastics, the Modifier chain decides how each row is set out and drawn. You don't need to wire any XML to add this MiniApp to your setContent block or preview it.
There are some things you should avoid from the start. Don't hide vital state deep in a leaf composable. If more than one area of the tree needs the same value, raise it up and pass it down. Don't handle I/O or long-running work directly in composition. Instead, use side effects like LaunchedEffect(key) to launch coroutines when particular inputs change and DisposableEffect to clean up. And remember the order of the modifiers: size and layout come first, then visual adornment like background or clip, and last interaction. If something seems "off," switching two modifiers usually fixes it.
You don't have to go all-in if you're moving a screen from XML. With ComposeView, you can host Compose inside existing layouts, and with AndroidView, you can host classic views inside Compose. This enables you replace parts one at a time, starting with the ones that will benefit the most from declarative state, such forms or item rows.
Testing is easy and straightforward. You can use Compose to create a semantics tree for your UI that you can query in tests. You can set the content, click on a node by text or tag, and check that the new text exists. These tests are usually reliable and don't rely on timing hacks because your UI is based on state.
You now know enough to pose a threat. The next levels, which are navigation, scaling lists, side effects, and performance, all use the same mental model. When you do look into performance, pay attention to stability (use immutable models and val properties), keep recompositions modest, and use aids like derivedStateOf and snapshotFlow when you need to get values or connect to coroutines.
Compose encourages tiny, composable functions, unambiguous ownership of state, and self-control. If you keep these few basic ideas in mind, you'll find that much UI work is easier to think about. Let me know what you want next: a more in-depth look at navigation, lists at scale, or a gentle tour of side effects and LaunchedEffect.