Compose Mental Model & Phases Explained
UI › Compose Core
When an interviewer asks about Jetpack Compose, they are usually testing whether you understand it as a declarative rendering system, not a toolkit for mutating views. In the old View system, you build a tree once and imperatively call things like setText or setVisibility on it forever after. Compose throws that model out.
A **composable function** takes state in and describes what the UI should look like for that state, out. It does not return a View, a widget, or anything you attach to a window: its output is just a description that Compose's runtime turns into pixels. Because Compose is free to call a composable again whenever the state it depends on changes, that function has to behave the same way every time it runs: it must be **fast**, **idempotent**, meaning the same inputs always produce the same UI description, and completely **free of side effects**. No writing to a database, no mutating a shared variable, nothing that would behave differently, or badly, if it ran twice, zero times, or out of order. Every idea in this lesson, phases, recomposition, skipping, memoization, exists to make that one guarantee cheap to uphold.
That description becomes pixels through three phases, and they always run in the same order, every single frame:
1. **Composition**: decide *what* UI to emit. Composable functions run here, building or updating the UI tree. 2. **Layout**: decide *where* everything goes. Each node is measured, then placed. 3. **Drawing**: decide *how* it looks. Pixels actually get painted.
The order never changes, composition always precedes layout, which always precedes drawing. What does change, frame to frame, is which of the three actually has to redo its work. A state change doesn't automatically force all three phases to rerun: it depends on which phase reads that particular state. That's the piece most people miss, and it's exactly what makes the rest of this lesson worth knowing.
**Recomposition** is Compose re-running a composable because a State it read has changed. It's targeted, not a full-tree replay: Compose tracks exactly which composables read which state, and only those are candidates to rerun.
@Composable
fun Screen(name: String, count: Int) {
Greeting(name)
Counter(count)
}
Say count changes. Screen reruns its body, but that doesn't force Greeting to rerun too. Compose checks: does Greeting return Unit, which every composable effectively does unless it's marked @NonSkippable or @NonRestartable, and are all of its inputs **stable types with unchanged values**? If both hold, Compose **skips** re-running Greeting and reuses what it already has. Counter reruns because count, its actual input, changed.
That skip only works if Compose can trust the input's **stability**: if equals() says two values are equal, Compose has to be able to trust the UI they'd produce really is identical, and it must be notified of any real change.
Primitives, String, and Compose's own MutableState are all inferred stable automatically, and so is a data class built entirely from val properties. The trap is a class with a mutable var Compose can't observe:
class UiModel {
var title: String = "" // mutation is invisible to Compose
}
Nothing tells Compose when title changes, so it can neither safely skip a composable holding a UiModel, nor trust that two instances with equal fields really behave the same. When you know a type really is safe, you can promise the compiler directly: @Immutable says a type's public properties never change once constructed, and @Stable makes a similar promise for types that do notify Compose of changes. Either annotation lets Compose treat the type as stable and skip recomposition when the same, unchanged instance comes through again.
Compose also needs to know *which instance is which* across frames, separate from stability. It identifies a composable by its **call site**, the exact spot in source where it's called, plus **execution order** for repeated calls from that same spot. That's **positional memoization**, and it's how a remembered value gets found again on the next recomposition.
It breaks down in loops. Emit a row for every item with no explicit identity, and each instance is numbered purely by where it landed in execution order:
movies.forEach { movie ->
MovieRow(movie) // identity = position in the loop
}
Insert a new movie at the top of the list, and every row *after* it shifts position by one. Compose has no way to tell that a row is 'the same' MovieRow it was last frame, since positionally it isn't, so it treats each shifted row as a new instance and recomposes all of them, even the ones whose underlying data never changed. Wrapping each call gives it identity tied to the data instead of its position:
movies.forEach { movie ->
key(movie.id) {
MovieRow(movie)
}
}
Positional memoization is also what remember relies on. remember stores a value in Compose's internal slot table, keyed by call site, and hands the same value back on later recompositions instead of recreating it.
var count by remember { mutableStateOf(0) }
That value survives recomposition, but nothing more: leave the composition entirely, say the composable is removed from the tree, and the slot is discarded. A configuration change like a screen rotation, or process death, wipes it too, since the whole composition is thrown away and rebuilt. When a value needs to survive that, you reach for rememberSaveable instead, which saves it into the instance state bundle:
var count by rememberSaveable { mutableStateOf(0) }
Same call shape, different lifetime.
Here's why all of this purity matters so much: recomposition is not a polite, once-per-change event. Compose is free to **skip** it, **cancel and restart** it partway through, run it **very frequently**, and even run independent subtrees **concurrently** across cores, precisely because idempotent, side-effect-free functions are safe to treat that unpredictably. Composition specifically, running the composable functions themselves, is the phase where that parallelism is allowed; layout and drawing still run on the main thread today, since layout has ordering dependencies composition doesn't.
If a composable reaches out and mutates something directly, that unpredictability becomes a bug:
// BAD: could run 0 times, 1 time, or many times
@Composable
fun Bad() {
analytics.track("screen_viewed")
Text("Hello")
}
That call might fire once, fire several times if the composable restarts, or never if it's skipped. The fix is never to call it directly, but to run it through an effect API scoped to the composition's lifecycle instead.
So where do side effects actually go? Through an **effect API** that ties the effect to the composable's presence in the composition, instead of to how many times its body happens to run. For a one-time suspending action, like showing a snackbar when a screen first appears, that's LaunchedEffect:
val snackbarHostState = remember { SnackbarHostState() }
LaunchedEffect(Unit) {
snackbarHostState.showSnackbar("Welcome!")
}
LaunchedEffect launches a coroutine scoped to the composition. The key you pass it, Unit here, controls when that coroutine restarts: with a constant key, it launches exactly once when the composable enters the composition, and Compose cancels it automatically if the composable leaves. Calling a suspend function directly in the composable body isn't allowed, composables aren't suspend functions, and stuffing it inside remember would run it during composition itself, which is exactly the unpredictable timing you're trying to avoid.
Purity is also what makes it safe for Compose to defer a state read into a later phase, and that's one of the highest-value performance tricks in Compose: read state as late as possible, so only the phases that actually need it rerun.
Compare two ways to offset an Image by a scroll position:
// Reads state in composition -> recomposes on every scroll pixel
Modifier.offset(y = scrollState.value.dp)
// Reads state in the placement lambda -> only layout and drawing rerun
Modifier.offset { IntOffset(0, scrollState.value) }
The dp overload evaluates scrollState.value while building the UI tree, in composition, so every scroll pixel forces a full recomposition. The lambda overload defers that exact same read into layout's placement step, so composition never reruns at all, only the cheaper layout and drawing work does. The same logic explains why a state read inside a Canvas drawing lambda, say inside drawRect, only restarts the drawing phase: nothing about what to emit or where to place it changed, only how it looks.
Deferring the read to a later phase isn't the only lever. Sometimes you need the *result* in composition itself, but the underlying state changes far more often than the result actually does. derivedStateOf handles that case: it wraps a computation and only notifies readers when the **computed result** changes, not when the inputs feeding it change.
val listState = rememberLazyListState()
// BAD: recomposes on every single firstVisibleItemIndex change
val showButton = listState.firstVisibleItemIndex > 0
// GOOD: only notifies when the boolean result actually flips
val showButton by remember {
derivedStateOf { listState.firstVisibleItemIndex > 0 }
}
firstVisibleItemIndex can change on nearly every scroll frame, but showButton only actually flips twice: once past the top, once back to it. Wrapping the calculation in derivedStateOf means composables reading showButton only recompose on those two flips, not on every scroll event in between.
Layout has its own guarantee worth knowing: **single-pass measurement**. A parent measures each of its children exactly once per layout pass, gets back a size, and places it. Compose doesn't measure a node, look at its neighbors, and remeasure to reconcile, that would make layout cost grow explosively in a deep tree.
Layout(content = content) { measurables, constraints ->
val placeables = measurables.map { measurable ->
measurable.measure(constraints) // measured once per pass
}
val height = placeables.sumOf { it.height }
layout(constraints.maxWidth, height) {
var y = 0
placeables.forEach { placeable ->
placeable.placeRelative(0, y)
y += placeable.height
}
}
}
When a layout genuinely needs a child's size before deciding its own constraints, the escape hatch is **intrinsics**, an explicit, opt-in extra measurement pass, not a change to the default single-pass rule. And to be clear about what layout is *not* doing: deciding which composables exist happens earlier, in composition. Layout only measures and places what composition already decided to emit.
Step back and this is really one idea wearing different clothes: Compose is built around **unidirectional data flow**, state flows down, events flow up. A composable that owns no state of its own, and instead receives a value plus a callback for reporting changes, is easier to skip, easier to test, and easier to reuse, because there's exactly one source of truth for that state.
@Composable
fun SearchBar(
query: String,
onQueryChange: (String) -> Unit
) {
TextField(value = query, onValueChange = onQueryChange)
}
@Composable
fun Screen() {
var query by remember { mutableStateOf("") }
SearchBar(query = query, onQueryChange = { query = it })
}
SearchBar never mutates its own state, it hands the new value up through onQueryChange and waits to be handed a fresh query on the next pass. Screen is the single source of truth. This is called **state hoisting**, and it's the same discipline that makes stability and skipping work at all: a composable driven entirely by its parameters is trivially easy for Compose to reason about, since there's nowhere for hidden state to hide.
All of these ideas collide in one trap worth knowing by name: the **recomposition loop**, sometimes called a layout loop. It happens when a value written in a *later* phase gets read back in an *earlier* one, breaking the fixed composition-then-layout-then-drawing order you started this lesson with.
var boxSize by remember { mutableStateOf(IntSize.Zero) }
Box(Modifier.onSizeChanged { boxSize = it }) {
// reading boxSize here, in composition, re-triggers composition
// every time layout finishes measuring, one frame behind
}
onSizeChanged fires during layout. Writing its result to state that composition reads means every settled layout produces a fresh composition, which produces a fresh layout, which writes again, and the UI visibly jumps for a frame before it settles. The fix is the same discipline as state hoisting: keep a single source of truth, and reach for a proper layout primitive, a custom Layout, or intrinsics, instead of feeding a later phase's result back into an earlier one.
That's the whole mental model in one line: composition decides what exists, layout decides where, drawing decides how it looks, each phase only reruns when something it actually reads changes, and every rule about purity, stability, and identity exists to make that cheap and correct. If an interviewer asks why a screen is recomposing more than expected, this is the checklist: what phase is the state actually read in, is the input stable, does the composable have real identity, and is anything writing a later phase's result back into an earlier one.