What you'll learn
- Quick answer
- Why Compose Is a Different Way of Building Android UI
- Your First Composable Function
- State and Recomposition: How the UI Actually Updates
- Layouts: Column, Row, Box, and Why Modifier Order Matters
- Material 3 Components: Buttons, Text Fields, and Scaffold
- Lists: LazyColumn Instead of RecyclerView
- State Hoisting and Wiring Up a Real ViewModel
- FAQ
Quick Answer
Jetpack Compose is Android's modern UI toolkit: you describe what a screen should look like as Kotlin functions marked @Composable, and the framework re-runs the relevant functions automatically whenever the state they read changes. There's no XML layout file, no findViewById, and no manual code to keep the UI in sync with your data — Compose handles that part for you.
Why Compose Is a Different Way of Building Android UI
Before Compose, building an Android screen meant writing an XML layout file to describe the view hierarchy, then writing Kotlin code that finds each view by its ID with findViewById and manually pushes data into it. If a user's name changed somewhere in your data layer, you had to remember every place in your code that displayed that name and call .text = newName on each one, by hand. Miss a spot and the UI goes stale silently — no crash, no warning, just a screen showing the wrong thing until someone notices in QA or, worse, production.
Compose inverts this relationship. You write a Kotlin function, mark it with the @Composable annotation, and inside it you describe what the UI should look like given the current data, using plain function calls instead of an XML tree. When that data changes, Compose doesn't wait for you to remember to update anything — it automatically re-runs the parts of your composable functions that actually read the changed data, a process called recomposition, and skips the parts that don't depend on it. You stop writing "how do I update the UI when X changes" code entirely, because the framework already knows which functions depend on X.
This isn't just a syntax change from XML tags to Kotlin function calls. It's a shift from an imperative model (find the view, mutate it) to a declarative one (describe the output as a function of the input). React and SwiftUI made the same shift on the web and iOS respectively, and if you've used either, the mental model here will feel familiar — a composable function is conceptually the same idea as a React component or a SwiftUI view.
Your First Composable Function
A composable is just a Kotlin function annotated with @Composable. It doesn't return a UI object — it calls other composables (like Text or Column) that describe pieces of the screen, and the Compose runtime handles turning that description into actual pixels. Here's a minimal example, including how you connect it to an Activity with setContent:
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
Greeting(name = "Priya")
}
}
}
@Composable
fun Greeting(name: String) {
Text(
text = "Hello, $name!",
modifier = Modifier.padding(16.dp)
)
}setContent (imported from androidx.activity.compose.setContent) is the bridge between the old Activity-based world and Compose — it's the one place where you hand control of the screen to the Compose runtime. Everything inside that lambda is a composable function call. Notice there's no layout XML file anywhere, no R.layout.activity_main, and no inflate step. Greeting takes a plain Kotlin String parameter, which is the normal way composables receive data — they're just functions, so they take arguments like any other function.
You can preview a composable without running the full app by adding a function annotated with @Preview above it in Android Studio, which renders it directly in the IDE. This alone removes a huge amount of the build-and-deploy-to-see-a-button-move cycle that XML layouts required.
State and Recomposition: How the UI Actually Updates
A composable function by itself is stateless — call it twice with the same arguments and you get the same UI both times. To make part of the screen change over time (a checkbox that toggles, a counter that increments), Compose needs to track a piece of state and know that this particular composable should re-run when that state changes. That's what remember and mutableStateOf do together:
@Composable
fun Counter() {
var count by remember { mutableStateOf(0) }
Column(modifier = Modifier.padding(16.dp)) {
Text(text = "Count: $count")
Button(onClick = { count++ }) {
Text("Increment")
}
}
}mutableStateOf(0) creates an observable holder of type MutableState<Int>. remember tells Compose to keep that same holder alive across recompositions instead of creating a fresh one (with the value reset to 0) every time the function re-runs. The by keyword is Kotlin's property delegate syntax — it lets you read and write count directly as an Int instead of writing count.value everywhere. When count++ runs inside the button's onClick, Compose knows exactly which composables read count (in this case, the Text showing it) and re-runs only those, not the entire screen.
This is worth sitting with, because it's the single most important mechanic in Compose: recomposition is scoped to whatever actually reads the changed state, not to the whole function tree. A large screen with fifty composables where only one reads a particular piece of state will only recompose that one when the state changes — the other forty-nine are skipped. This is also why you should keep state as narrowly scoped as possible; hoisting a piece of state too high up the tree can make more of the UI recompose than necessary.
One more distinction that trips people up early: remember survives recomposition but not a configuration change like a screen rotation, because the Activity and its whole composition get torn down and rebuilt. For state that needs to survive that, use rememberSaveable instead — same API, but it saves the value into the instance state bundle.
Layouts: Column, Row, Box, and Why Modifier Order Matters
Compose has three foundational layout composables: Column stacks children vertically, Row stacks them horizontally, and Box stacks them on top of each other (useful for overlays like a badge on an icon). There's no XML equivalent of LinearLayout or ConstraintLayout to configure separately — you nest these three primitives directly in Kotlin:
@Composable
fun ProfileCard(name: String, title: String) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
verticalAlignment = Alignment.CenterVertically
) {
Box(modifier = Modifier.size(48.dp)) {
// avatar placeholder
}
Column(modifier = Modifier.padding(start = 12.dp)) {
Text(text = name, style = MaterialTheme.typography.titleMedium)
Text(text = title, style = MaterialTheme.typography.bodyMedium)
}
}
}Every layout composable takes a modifier parameter, and modifiers are where a lot of Compose's real expressiveness lives — they chain together to describe padding, size, click handling, background color, and dozens of other cross-cutting concerns, all without needing separate XML attributes or a StateListDrawable. The order you chain them in changes the result, because each modifier wraps the one before it. Modifier.padding(16.dp).size(48.dp) adds padding and then constrains the padded box to 48dp total. Modifier.size(48.dp).padding(16.dp) constrains to 48dp first and then pads inward, leaving a smaller visible area. This bites almost everyone the first time, and the fix is just to read modifier chains as "apply this, then apply that" from top to bottom, the same way you'd read a sequence of function calls wrapping a value.
Material 3 Components: Buttons, Text Fields, and Scaffold
Compose ships Google's current design system, Material 3, as a library you import from androidx.compose.material3 — components like Button, Text, OutlinedTextField, Surface, and layout scaffolding like Scaffold and TopAppBar. A typical screen combines a form field with some conditional UI:
@Composable
fun NameForm() {
var name by remember { mutableStateOf("") }
Column(modifier = Modifier.padding(16.dp)) {
if (name.isNotEmpty()) {
Text(
text = "Hello, $name!",
modifier = Modifier.padding(bottom = 8.dp),
style = MaterialTheme.typography.bodyMedium
)
}
OutlinedTextField(
value = name,
onValueChange = { name = it },
label = { Text("Name") }
)
}
}OutlinedTextField is a good example of Compose's controlled-input pattern: it doesn't manage its own text internally. You pass it the current value and an onValueChange callback, and it's entirely up to you to store the new value (here, into the name state) and pass it back in. This is the same controlled-component pattern React uses for form inputs, and it means the text field's displayed value is always a direct reflection of your state — there's no separate "what the field actually contains" to get out of sync with "what you think it contains."
For a full screen with an app bar, use Scaffold, which lays out the standard screen regions (top bar, bottom bar, floating action button, content area) and hands you a PaddingValues object so your content doesn't draw underneath the app bar.
Lists: LazyColumn Instead of RecyclerView
Displaying a scrolling list used to mean setting up a RecyclerView, writing an Adapter class, a ViewHolder class, overriding onCreateViewHolder and onBindViewHolder, and wiring a DiffUtil.Callback if you wanted efficient updates. Compose replaces all of that with LazyColumn (or LazyRow for horizontal lists):
@Composable
fun ContactList(contacts: List<String>) {
LazyColumn {
items(contacts) { contact ->
Text(
text = contact,
modifier = Modifier.padding(12.dp)
)
}
}
}The "lazy" in LazyColumn means exactly what it means in RecyclerView: only the items currently visible on screen (plus a small buffer) get composed and measured. Scroll a list of ten thousand contacts and Compose is only ever holding a handful of them in memory as active composables at any moment — you get RecyclerView's performance characteristic without writing an adapter, a view holder, or any recycling logic yourself, because the recycling is handled internally by the lazy layout.
Passing a stable key to items(contacts, key = { it.id }) when your list can reorder or have items inserted or removed in the middle matters for correctness, not just performance — without a key, Compose identifies items by their position in the list, so inserting an item at the top can make Compose think every existing item "changed" and trigger a wave of recomposition and lost per-item state (like scroll position within a row, or an expanded/collapsed flag) that shouldn't have been lost.
State Hoisting and Wiring Up a Real ViewModel
The counter example earlier kept its state inside the composable itself with remember, which is fine for something purely local and disposable. Real screens need state that survives longer, gets shared between composables, or comes from a network call — and for that, Compose's convention is state hoisting: move the state up to a caller, and pass the value down along with a callback to change it, so the composable displaying the value stays stateless and easy to test.
@Composable
fun NameField(name: String, onNameChange: (String) -> Unit) {
OutlinedTextField(value = name, onValueChange = onNameChange, label = { Text("Name") })
}
@Composable
fun ProfileScreen(viewModel: ProfileViewModel = viewModel()) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
NameField(
name = uiState.name,
onNameChange = viewModel::onNameChanged
)
}NameField no longer owns any state at all — it's a pure function of its parameters, which makes it trivial to preview, reuse, and unit test without spinning up a ViewModel. ProfileScreen is where the state actually lives, exposed from a ViewModel as a StateFlow and collected with collectAsStateWithLifecycle(), which is the recommended way to bridge a Flow into Compose because it automatically pauses collection when the screen isn't visible, avoiding wasted work and potential crashes from updating UI that isn't on screen. This pattern — stateless composables at the leaves, state hoisted up to a screen-level composable backed by a ViewModel — is how you scale Compose past toy examples into an app with real business logic and navigation.
