Views were mutated: find a widget, call a setter. Compose calls the same function again with different arguments. The price: a composable must be fast, idempotent and free of side effects; it can run in any order, be skipped when its parameters have not changed, and run as often as every animation frame. Recomposition is optimistic, too: if a parameter changes mid-pass, Compose may cancel and restart.
So a counter incremented inside a composable is reset by the next recomposition and observed by nothing.
@Composable
fun Tally() {
var loose = 0 // reset every recomposition, observed by nothing
var kept by remember { mutableStateOf(0) } // observable, and survives recomposition
Button(onClick = { loose++; kept++ }) { Text("$loose / $kept") }
}
mutableStateOf makes a value observable; a plain ArrayList is not, so mutating one shows stale data. remember survives recomposition only; rememberSaveable also survives the activity being recreated. Shared state moves up to the nearest common parent, leaving the child stateless with value and onValueChange: state down, events up.
Work with consequences goes in an effect. LaunchedEffect(key) starts a coroutine on entering composition, cancels it on leaving, and restarts it when the key changes; DisposableEffect must end in onDispose; rememberUpdatedState lets a long-running effect see the newest value without restarting. And LaunchedEffect(true) is as suspicious as while(true).
Underneath, each frame runs three phases: composition decides what to show, layout where, drawing how. Layout visits each node once, so cost grows linearly rather than exponentially, and you cannot measure a child twice to see what fits. Compose tracks which phase read which state, so a read deferred into a layout-phase lambda skips composition entirely.
// reads the scroll position during composition — recomposes on every scroll frame
Modifier.offset(y = with(density) { (state.firstVisibleItemScrollOffset / 2).toDp() })
// same effect, deferred into the layout phase — composition is skipped
Modifier.offset { IntOffset(x = 0, y = state.firstVisibleItemScrollOffset / 2) }
A Column with verticalScroll composes every child, visible or not; LazyColumn composes only what fits. Nesting one scrollable inside another in the same direction throws IllegalStateException unless the inner one has a fixed size. The trap is keys: without a key, item state is tied to position, so reordering the data leaves a row's remembered scroll offset at the old slot, on a different item.
Rule: if a line would still matter when the screen is thrown away mid-render, keep it out of the composable body, and when a value changes every frame, ask which phase reads it.