Composables redraw from state
Think of a stadium scoreboard. Nobody repaints the numbers when a team scores: someone updates the score, and the board shows it.
In Compose, your UI is a function. You don’t change a TextView directly. You change state, and Compose calls your composable function again to draw the new version. That re-run is called recompositionRecompositionJetpack Compose running a composable function again because state it reads has changed.Open in glossary →. For it to happen, Compose has to know your value changed, which is why state lives in special holders like mutableStateOf.
Compose snapshots track reads of State objects during composition. When a State that a composable read is written, that composable’s restart scope is invalidated and re-executed on the next frame; composables whose parameters are unchanged (and stable, or equal under strong skipping) are skipped. Remembered values live in the slot table, tied to the composable’s position in the composition, and are forgotten when it leaves.
So: writes to non-snapshot objects aren’t seen, remember lasts as long as the call site stays in the composition, and configuration changes destroy the composition unless you save state elsewhere.
A counter, broken twice
The best way to see what remember and mutableStateOf do is to leave them out. Here’s the counter most people write first:
@Composable
fun Counter() {
var count = 0
Button(onClick = { count++ }) {
Text("Tapped $count times")
}
}Tap it ten times and the button still says Tapped 0 times. The tap really does change count: it’s 10 now. But count is an ordinary Kotlin variable, and Compose has no idea it changed, so nobody redraws the button. It’s like updating the score in your notebook while the scoreboard stays dark.
And if anything else makes Compose redraw this button (the parent screen changes, the user switches to dark mode), Counter() runs again from the first line, and var count = 0 starts over.
So the value needs to be one Compose watches:
var count by mutableIntStateOf(0)Now a tap does make Compose redraw. Here’s what happens, step by step:
- The tap sets
countto 1. Compose notices: “Counterread this value, soCountermust be drawn again.” - To redraw, Compose calls
Counter()again, from the top. - The first line runs again:
mutableIntStateOf(0)creates a brand new state that holds 0. - The button shows Tapped 0 times. The 1 was in the old state, which nobody points to anymore.
Compose did its job perfectly; the function threw the value away. (Android Studio even marks this line as an error: “Creating a state object during composition without using remember”.)
The fix is to keep the state object between runs of the function. That’s exactly what remember does:
var count by remember { mutableIntStateOf(0) }The first time Counter() runs, remember runs the code in the braces and stores the result, tied to this spot on the screen. Every later run, it skips the braces and hands back the stored state. So the tap sets count to 1, Compose redraws, remember returns the same state, and the button finally shows 1.
Each half fixes one problem:
| Compose redraws on a tap? | Value survives the redraw? | After 3 taps | |
|---|---|---|---|
var count = 0 | No | No | Tapped 0 times |
mutableIntStateOf(0) | Yes | No | Tapped 0 times |
remember { mutableIntStateOf(0) } | Yes | Yes | Tapped 3 times |
Try all three. “Something else redraws” simulates a redraw the counter didn’t cause:
Three counters, one difference each
A plain variable
var count = 0count in memory: 0
- Tap the button.
State, not remembered
var count by mutableIntStateOf(0)count in memory: 0
- Tap the button.
Remembered state
var count by remember { mutableIntStateOf(0) }count in memory: 0
- Tap the button.
Dev note
by delegate needs import androidx.compose.runtime.getValue and setValue; Android Studio adds them when you accept its suggestion.The three attempts fail for different reasons. A plain local is invisible to the snapshot system, so no write invalidates anything. An unremembered mutableIntStateOf is a real snapshot object, so the write invalidates Counter, but recomposition re-executes the initializer and the new object replaces the old one: the state lives exactly one composition. remember stores the object in the slot table at this call site, so it survives every recomposition until the call site leaves the composition (an if turns false, a list item scrolls away, the activity is recreated).
Three counters, one difference each
A plain variable
var count = 0count in memory: 0
- Tap the button.
State, not remembered
var count by mutableIntStateOf(0)count in memory: 0
- Tap the button.
Remembered state
var count by remember { mutableIntStateOf(0) }count in memory: 0
- Tap the button.
Tap the line with the bug.
@Composable
fun LikeButton() {
var liked by mutableStateOf(false)
IconButton(onClick = { liked = !liked }) {
Icon(
imageVector = if (liked) Icons.Filled.Favorite else Icons.Outlined.FavoriteBorder,
contentDescription = if (liked) "Unlike" else "Like",
)
}
}Hint: Tapping changes liked, which triggers a recomposition. What does the first line of the function do when it runs again?
The state isn’t wrapped in remember. The tap sets liked = true, Compose recomposes LikeButton, and that recomposition runs mutableStateOf(false) again: a fresh state, back to false. Android Studio’s lint flags this pattern (“Creating a state object during composition without using remember”).
var liked by remember { mutableStateOf(false) }Recomposition, skipping and stability
Take a search screen: a text field on top, 500 products below. The user types “s”, “h”, “o”, “e”, “s”.
data class SearchUiState(val query: String = "", val products: List<Product> = emptyList())
@Composable
fun SearchScreen(state: SearchUiState, onQueryChange: (String) -> Unit) {
SearchField(state.query, onQueryChange) // must redraw: query changed
ProductList(state.products) // does it?
}Every keystroke produces a new state, so SearchScreen recomposes and calls ProductList again. Compose then decides whether ProductList can be skipped: if its parameters are “the same as last time”, it reuses what it drew before. What “the same” means is the whole story.
- Stable types are compared with
equals. AString, anInt, or adata classwith onlyvals of stable types is stable: Compose can trust that equal means unchanged. - Unstable types are everything Compose can’t prove won’t change behind its back:
List<T>(it’s an interface, and the object behind it might be aMutableList), classes withvarproperties, and classes from modules compiled without the Compose compiler.
Before Kotlin 2.0.20, an unstable parameter made a composable never skippable. ProductList(products: List<Product>) ran on all five keystrokes, even though the products didn’t change. Teams wrapped lists in @Immutable classes or used kotlinx.collections.immutable just to make skipping work.
With strong skipping (on by default with the Compose compiler since Kotlin 2.0.20), unstable parameters are compared by instance (===). state.copy(query = "sh") keeps the same products list object, so ProductList is skipped four times out of five. Lambdas are also remembered automatically, so onClick = { vm.open(product.id) } no longer counts as a new parameter every time.
The trap that’s left is creating new instances with the same contents:
// In the ViewModel: maps all products again for every query
val state = query.map { q -> SearchUiState(q, repo.products.map { it.toUi() }) }map { it.toUi() } builds a new list on every emission. It’s equals to the old one, but not ===, so ProductList recomposes on every keystroke again. Map the products once (or only when they change), and let the query change only the query.
How to check instead of guessing: in Android Studio’s Layout Inspector, turn on recomposition counts, type “shoes”, and look at ProductList: 0 means it was skipped, 5 means it wasn’t. The Compose compiler can also write reports (composeCompiler { reportsDestination = layout.buildDirectory.dir("compose_compiler") }) that list every composable as skippable or not, and every parameter as stable or unstable.
Reading state as late as possible
Compose draws a frame in three phases: composition (run composables, decide what to show), layout (measure and place) and draw. A state read triggers the phase it happens in, and everything after it. Recomposition is the most expensive, so state that changes every frame should be read as late as possible.
A collapsing header that moves at half the scroll speed:
// Reads scrollState.value during composition:
// 60 recompositions per second while the user scrolls.
Header(modifier = Modifier.offset(y = (-scrollState.value / 2).dp))
// Reads it inside the offset lambda, during layout:
// no recomposition at all, only the header is moved.
Header(modifier = Modifier.offset { IntOffset(0, -scrollState.value / 2) })The same idea in another shape: a “Scroll to top” button that shows once the user has scrolled.
// Recomposes every time the first visible index changes: 1, 2, 3, … 40
val showButton = listState.firstVisibleItemIndex > 0
// Recomposes only when the answer flips between false and true
val showButton by remember { derivedStateOf { listState.firstVisibleItemIndex > 0 } }derivedStateOf is worth it exactly when the result changes much less often than its input. For fullName = "$first $last", which changes whenever its inputs do, it only adds overhead.
When does a state write recompose?
Predict the output
The user taps the button once. What new lines does the log show?
@Composable
fun DoubleCounter() {
var count by remember { mutableIntStateOf(0) }
SideEffect { Log.d("Counter", "count = $count") }
Button(onClick = {
count++
count++
}) {
Text("Add two")
}
}State writes in an event handler go into the global snapshot and only mark readers as invalid. Recomposition runs once, before the next frame, and sees the final value: one count = 2. That's why several writes in one handler are cheap, and also why reading state right after writing it inside composition is a smell: the UI shows the value from the next frame, not the line you just ran.
- Compose records which
Stateobjects a composable reads; a write invalidates those readers, and they re-run once before the next frame. Two writes in one click handler give one recomposition that sees the final value. rememberstores a value in the slot table at the call site’s position.mutableStateOfwithoutremembercreates a fresh state on every recomposition (lint warns about it).- Skipping: with strong skipping (default since Kotlin 2.0.20), stable parameters are compared with
equals, unstable ones (likeList<T>) by instance. A new list on every emission recomposes even with equal contents. - Read late:
Modifier.offset { IntOffset(…) }reads scroll state in the layout phase, not composition;derivedStateOfturns a fast-changing input into a rarely changing value.
Refresher:What recomposition is
Composables are functions that describe the UI. When state they read changes, Compose calls them again with the new values and updates only what changed on screen. That re-run is recomposition.
Check this part
What does
rememberdo?var liked by mutableStateOf(false)is written withoutremember. The user taps andlikedbecomestrue. What happens?With strong skipping, a composable takes an unstable
List<Product>. When can it skip?Which modifier avoids recomposing the composable on every scroll frame?