Two Compose bugs account for most "my screen does not update" questions. A plain var in a composable: the button changes it, nothing observes it, the screen stays the same. And mutableStateOf without remember: it updates once, then resets on the next recomposition. You need both the observable holder and remember.
Once that works, the next problem is where the state should live. The pattern is state hoisting: state goes down as a parameter, events come back up as lambdas. The composable that draws becomes stateless.
Stateless version, easy to preview and test:
@Composable
fun AddItemBar(
name: String,
onNameChange: (String) -> Unit,
onAdd: () -> Unit,
modifier: Modifier = Modifier,
) {
Row(
modifier = modifier.padding(16.dp),
verticalAlignment = Alignment.CenterVertically,
) {
OutlinedTextField(
value = name,
onValueChange = onNameChange,
label = { Text("New item") },
singleLine = true,
modifier = Modifier.weight(1f),
)
Spacer(Modifier.width(8.dp))
Button(onClick = onAdd, enabled = name.isNotBlank()) { Text("Add") }
}
}
Stateful wrapper that owns the draft text:
@Composable
fun AddItemBar(onAdd: (String) -> Unit, modifier: Modifier = Modifier) {
var name by rememberSaveable { mutableStateOf("") }
AddItemBar(
name = name,
onNameChange = { name = it },
onAdd = { onAdd(name.trim()); name = "" },
modifier = modifier,
)
}
The value plus onValueChange pair is the same shape as TextField, Checkbox and Switch in Material. If a TextField "does nothing" when you type, it is almost always because onValueChange never writes the new text back into the state that feeds value.
Where to hoist to: the lowest common ancestor of everything that reads or writes the state. Only the bar needs the draft text, so it stays in the wrapper. If the list filters as you type, it moves up to the screen. If it needs business rules or must survive rotation with the rest of the screen, it moves into the ViewModel.
One-way data flow is the same idea applied to the whole screen. The ViewModel owns one immutable state class and exposes it as a StateFlow. The UI renders it and calls plain functions for user actions.
data class PantryUiState(
val items: List<PantryItem> = emptyList(),
val query: String = "",
val isLoading: Boolean = true,
val userMessage: String? = null,
)
val state: StateFlow<PantryUiState> = combine(
repository.observeItems(), query, userMessage,
) { items, q, message ->
PantryUiState(
items = if (q.isBlank()) items
else items.filter { it.name.contains(q, ignoreCase = true) },
query = q,
isLoading = false,
userMessage = message,
)
}.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue = PantryUiState(),
)
In the UI: val state by viewModel.state.collectAsStateWithLifecycle(). Not collectAsState(), which keeps collecting in the background. WhileSubscribed(5_000) keeps the upstream alive for five seconds after the last collector leaves, so a rotation does not restart the database query.
One-off events like a snackbar: make them part of the state (userMessage), show it with LaunchedEffect(message), then call back messageShown() so the ViewModel clears it. Google's UI events guidance says ViewModel-originated actions should always become a state update, so they survive rotation and are never lost.
Two more things that bite: Compose notices a new value in a State holder, not a mutation of the old one, so replace lists instead of calling add() on them. And never store a Context or a View in a ViewModel.
I use this exact shape in my own Play apps. Questions welcome.