Compose state, recomposition and Material 3
remember and mutableStateOf, state hoisting, why recomposition scope matters, derivedStateOf, and Material 3 theming with dynamic colour.
State and recomposition
@Composable
fun Counter() {
// remember survives recomposition; mutableStateOf makes reads observable
var count by remember { mutableStateOf(0) }
// rememberSaveable survives configuration changes and process death
var query by rememberSaveable { mutableStateOf("") }
Column(Modifier.padding(16.dp)) {
Text("Count: " + count)
Button(onClick = { count++ }) { Text("Increment") }
OutlinedTextField(value = query, onValueChange = { query = it })
}
}
// state hoisting: the composable is stateless and the caller owns the state
@Composable
fun SearchField(query: String, onQueryChange: (String) -> Unit, modifier: Modifier = Modifier) {
OutlinedTextField(value = query, onValueChange = onQueryChange, modifier = modifier)
}
@Composable
fun SearchScreen(vm: SearchViewModel = hiltViewModel()) {
val query by vm.query.collectAsStateWithLifecycle()
SearchField(query = query, onQueryChange = vm::onQueryChange)
}- A composable re-runs when a state it read changes. Reading state inside a lambda that is not called during composition does not subscribe - that is the usual reason an update is ignored.
rememberwithout keys is computed once; pass keys (remember(id) { ... }) when the value depends on an input that can change.rememberSaveablefor anything the user typed or scrolled: without it, a rotation or a low-memory process death loses the value.derivedStateOffor a value computed from other state where you want recomposition only when the derived result changes, not on every input change.
💡
Compose skips a composable when its parameters are equal and stable. Passing a new lambda instance on every recomposition breaks skipping, which is why the compiler suggests hoisting or using a method reference. Prefer a stable data class or an immutable collection as a parameter.
Keeping recomposition narrow
@Composable
fun BookList(books: List<Book>, onSelect: (Long) -> Unit) {
LazyColumn {
items(books, key = { it.id }) { book ->
BookRow(book = book, onClick = { onSelect(book.id) })
}
}
}
@Composable
private fun BookRow(book: Book, onClick: () -> Unit) {
Row(Modifier.fillMaxWidth().clickable(onClick = onClick).padding(16.dp)) {
Text(book.title, style = MaterialTheme.typography.bodyLarge)
}
}
// avoid: reading scroll state in a parent, which recomposes the whole list
val expensive = remember(books) { books.filter { it.pages > 100 } }
// use a lambda to defer the state read into a child's scope
Box(Modifier.drawBehind { /* reads state only at draw time */ })| Pattern | Effect |
|---|---|
key = { it.id } in a lazy list | Correct item identity on reorder and delete, and state preserved per item |
| Unstable parameter type | Skipping is disabled for that composable |
collectAsStateWithLifecycle | Collection stops when the screen is not visible |
| Mutable list mutated in place | No recomposition - the reference did not change |
Modifier as the first optional parameter | Convention, and it lets callers wrap your composable |
Mutating a list rather than replacing it is the most common Compose bug: the composable holds the same reference, so equality says nothing changed and no recomposition happens. Always publish a new list instance.
Material 3 theming
@Composable
fun AppTheme(
darkTheme: Boolean = isSystemInDarkTheme(),
dynamicColor: Boolean = true,
content: @Composable () -> Unit,
) {
val colorScheme = when {
dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
val context = LocalContext.current
if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
}
darkTheme -> DarkColorScheme
else -> LightColorScheme
}
MaterialTheme(
colorScheme = colorScheme,
typography = AppTypography,
content = content,
)
}
@Composable
fun BookCard(book: Book) {
ElevatedCard(Modifier.fillMaxWidth().padding(8.dp)) {
Column(Modifier.padding(16.dp)) {
Text(book.title, style = MaterialTheme.typography.titleMedium)
Text(book.subtitle ?: "", style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant)
FilledTonalButton(onClick = {}) { Text("Open") }
}
}
}- Read every colour from
MaterialTheme.colorScheme. A hard-coded hex value breaks dark theme and dynamic colour, and the failure only shows on a device with a different wallpaper. - Dynamic colour is Android 12+; always ship a fallback scheme, because the app must look correct on older devices.
- Use the role names (
surface,onSurface,primaryContainer) and contrast is handled for you. Pickingprimaryas a background usually fails contrast. - Provide content descriptions for icons and images. Material 3 gives visual defaults, not accessibility defaults.
FAQ
remember or ViewModel for screen state?
ViewModel for anything that must survive configuration changes and represents the screen's data;
remember for short-lived UI state such as an expanded row or a text field's focus. Hoist the ViewModel state down and keep the composables stateless.Why does my UI not update when the list changes?
You mutated the list in place. Compose compares the parameter by equality, and the reference is unchanged, so the composable is skipped. Emit a new list from the ViewModel.
Related
Layouts and Jetpack Compose Architecture: ViewModel, lifecycle and saved state
Last refreshed 2026-09-18.