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.
  • remember without keys is computed once; pass keys (remember(id) { ... }) when the value depends on an input that can change.
  • rememberSaveable for anything the user typed or scrolled: without it, a rotation or a low-memory process death loses the value.
  • derivedStateOf for 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 */ })
PatternEffect
key = { it.id } in a lazy listCorrect item identity on reorder and delete, and state preserved per item
Unstable parameter typeSkipping is disabled for that composable
collectAsStateWithLifecycleCollection stops when the screen is not visible
Mutable list mutated in placeNo recomposition - the reference did not change
Modifier as the first optional parameterConvention, 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. Picking primary as 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.

Layouts and Jetpack Compose Architecture: ViewModel, lifecycle and saved state

Last refreshed 2026-09-18.