State down, events up
Think of a scoreboard at a basketball game:
- The scorekeeper (the ViewModel) is the only one who changes the score.
- The scoreboard (the screen) just shows whatever the scorekeeper says. It never changes a number itself.
- When something happens on the court, the referee tells the scorekeeper (“two points for the home team”). That’s an event.
Because only one person changes the score, the board can never show two different scores, and if the board is replaced (rotation!), the new one shows the same score immediately.
A ViewModelMVVMModel-View-ViewModel: the UI observes a ViewModel that holds screen state and logic, separate from the UI code.Open in glossary → is Android’s scorekeeper: it survives rotation, holds the screen’s state, and receives the user’s actions.
UDF makes the screen a function of a single immutable UiState owned by a state holder (usually a ViewModel), with user input sent back as events (onAction(...) or individual functions). The ViewModel survives configuration changes, applies business logic, and exposes StateFlow<UiState>; the UI collects it lifecycle-aware and renders. It’s MVVM with the MVI discipline of a single state object.
The senior concerns are consistency and survival: atomic state updates under concurrency, one-off events (navigation, messages) without losing or replaying them, restoring after process death with SavedStateHandle without blowing the binder limit, text fields and asynchronous state, and testing the ViewModel as a pure state machine. All in the Detailed version.
data class TodoUiState(
val todos: List<Todo> = emptyList(),
val newTitle: String = "",
val isSaving: Boolean = false,
) {
val canAdd: Boolean get() = newTitle.isNotBlank() && !isSaving // derived, not stored
}
sealed interface TodoAction {
data class TitleChanged(val title: String) : TodoAction
data object AddClicked : TodoAction
data class Toggled(val id: Long) : TodoAction
}
class TodoViewModel(private val repository: TodoRepository) : ViewModel() {
private val _state = MutableStateFlow(TodoUiState())
val state: StateFlow<TodoUiState> = _state.asStateFlow() // read-only for the UI
init {
viewModelScope.launch {
repository.observeTodos().collect { todos -> _state.update { it.copy(todos = todos) } }
}
}
fun onAction(action: TodoAction) {
when (action) {
is TodoAction.TitleChanged -> _state.update { it.copy(newTitle = action.title) }
TodoAction.AddClicked -> addTodo()
is TodoAction.Toggled -> viewModelScope.launch { repository.toggle(action.id) }
}
}
private fun addTodo() {
val title = _state.value.newTitle.trim()
if (title.isEmpty()) return
viewModelScope.launch {
_state.update { it.copy(isSaving = true) }
repository.add(title)
_state.update { it.copy(newTitle = "", isSaving = false) }
}
}
}
@Composable
fun TodoScreen(viewModel: TodoViewModel = viewModel()) {
val state by viewModel.state.collectAsStateWithLifecycle()
TodoContent(state = state, onAction = viewModel::onAction)
}
@Composable
fun TodoContent(state: TodoUiState, onAction: (TodoAction) -> Unit) {
Column(Modifier.padding(16.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
TextField(
value = state.newTitle,
onValueChange = { onAction(TodoAction.TitleChanged(it)) },
modifier = Modifier.weight(1f),
)
Button(onClick = { onAction(TodoAction.AddClicked) }, enabled = state.canAdd) { Text("Add") }
}
LazyColumn {
items(state.todos, key = { it.id }) { todo ->
Row(Modifier.fillMaxWidth().clickable { onAction(TodoAction.Toggled(todo.id)) }) {
Checkbox(checked = todo.done, onCheckedChange = null)
Text(todo.title)
}
}
}
}
}Follow one tap through it:
- The user taps Add.
TodoContentdoesn’t change anything; it callsonAction(AddClicked): an event goes up. - The ViewModel saves the todo and updates
_statewithcopy(...): a new state object. - The screen collects
state, gets the newTodoUiState, and recomposes: state goes down.
Two details make it reliable: the UI only sees a read-only StateFlow, and the state is one immutable object, so a screen can never show half of an update. TodoContent has no ViewModel at all, which means you can preview it with any state.
Who’s allowed to change the state?
Tap the line with the bug.
class CartViewModel(private val repository: CartRepository) : ViewModel() {
val state = MutableStateFlow(CartUiState())
fun checkout() {
viewModelScope.launch {
repository.placeOrder(state.value.items)
}
}
}
// In the cart screen:
Button(onClick = { viewModel.state.value = viewModel.state.value.copy(items = emptyList()) }) {
Text("Clear cart")
}Hint: Who else can write to state? And what does the repository think is in the cart after Clear is tapped?
state is a public mutable flow, so any composable can write to it, and the cart screen does: “Clear cart” empties the list on screen without telling the repository. The UI and the data now disagree, and the next repository update (or a checkout) brings the items back, or orders things the user thinks they removed. With several screens writing directly, nobody can tell where a state change came from.
Keep the mutable flow private, expose a read-only one, and turn every change into a function (an event) on the ViewModel:
class CartViewModel(private val repository: CartRepository) : ViewModel() {
private val _state = MutableStateFlow(CartUiState())
val state: StateFlow<CartUiState> = _state.asStateFlow()
fun clear() {
viewModelScope.launch { repository.clear() } // the source of truth changes; state follows
}
}
Button(onClick = viewModel::clear) { Text("Clear cart") }Reducers and state granularity
As screens grow, onAction plus _state.update becomes an implicit reducer. Making it explicit (fun reduce(state: UiState, result: Result): UiState, pure and synchronous) separates deciding the next state from side effects (network, database, analytics), which run in the ViewModel and feed results back as new inputs. That’s MVI without a framework, and it makes the state logic unit-testable without coroutines.
Granularity matters for both correctness and performance: one UiState per screen keeps updates atomic, but composables should read only what they need (pass state.todos, not state) so Compose can skip unaffected parts. Derived values (canAdd, totals, filtered lists) are computed properties or combined flows, never separately stored fields that can drift.
Read, then write
Predict the output
Two coroutines on Dispatchers.Default each increment the state 10,000 times. What gets printed?
fun main() {
val state = MutableStateFlow(0)
runBlocking {
repeat(2) {
launch(Dispatchers.Default) {
repeat(10_000) { state.value = state.value + 1 }
}
}
}
println(state.value)
}state.value = state.value + 1 is a read followed by a separate write. Two threads read the same old value and both write old + 1, so updates are lost; the result varies per run. MutableStateFlow.update { it + 1 } retries with compare-and-set until no one else changed the value in between, so it always gives 20000. The same applies to _state.value = _state.value.copy(...) in ViewModels: on the main thread alone it's fine, but once repository callbacks or Dispatchers.Default work update state too, use update.
- One immutable
UiStateper screen in aStateFlow; expose it read-only, update atomically with_state.update { it.copy(...) }(never read-then-write from concurrent coroutines). - An explicit
reduce(state, result)separates deciding the next state from side effects and makes state logic testable without coroutines. Derived values are computed, never stored.
Check this part
In unidirectional data flow, how does the UI change the state?
Why does the ViewModel expose StateFlow instead of MutableStateFlow?
Why use _state.update { } instead of _state.value = _state.value.copy(...)?
A composable takes the whole UiState but uses only the list of todos. What does that cost?