Unidirectional Data Flow
The previous lesson's RecipeListScreen only ever read from
RecipeListViewModel — a read-only list is the easy case. The moment a screen needs to
change something, like RecipeBox's "Add Recipe" form where the user types a title, picks a category,
and adjusts prep time, it's tempting to just mutate some local remember state directly
inside the composable. Unidirectional Data Flow (UDF) is the discipline that stops
you from doing that, and it's the architectural backbone of every serious Compose app.
The Pattern: State Down, Events Up
UDF describes a one-way loop with exactly two directions of travel, never a shortcut between them:
| Direction | What travels | Who initiates it |
|---|---|---|
| Down | State (the current, immutable snapshot of data) | The ViewModel exposes it; the composable only reads it |
| Up | Events (a description of "what the user just did") | The composable reports it via a callback; the ViewModel decides what to do |
Crucially, a composable following UDF never mutates the ViewModel's state directly.
It doesn't call viewModel.title = newText. Instead, it calls something like
viewModel.onEvent(RecipeEvent.TitleChanged(newText)) and waits to be told, via
recomposition, what the new state actually is. The composable proposes; the ViewModel disposes.
Why Bother? A Single Source of Truth
If every composable in a screen were free to mutate shared state directly, you'd quickly end up with
several different places that can change "the current recipe title" — a text field's own
remember, a button's click handler, maybe a LaunchedEffect — all writing to
overlapping state with no single place to reason about the current truth or to validate a change
before accepting it. UDF collapses this to one source of truth (the ViewModel) and
one direction of causality: an event happens, the ViewModel decides how state should change in
response, and the UI re-renders from the new state. That makes bugs enormously easier to reason about
— if the title on screen is wrong, there is exactly one function, the ViewModel's event handler, that
could have produced it, instead of a dozen scattered mutation sites.
It also makes the UI trivially easy to test and preview: since a composable following UDF is just a
function of the state it's handed plus the callbacks it's given, you can render
AddEditRecipeScreen in a @Preview with any hand-built state and a no-op
lambda, with no real ViewModel involved at all.
Worked Example: RecipeBox's Add Recipe Screen
First, the shape of a user's possible actions on this screen is modeled explicitly as a sealed class of events, rather than as ad-hoc lambda parameters for every single field:
sealed class RecipeEvent {
data class TitleChanged(val title: String) : RecipeEvent()
data class IngredientsChanged(val ingredients: String) : RecipeEvent()
data class StepsChanged(val steps: String) : RecipeEvent()
data class CategoryChanged(val category: String) : RecipeEvent()
data class PrepMinutesChanged(val prepMinutes: Int) : RecipeEvent()
object SaveClicked : RecipeEvent()
}
The ViewModel holds the current form state and a single entry point, onEvent, that
handles every one of those events:
class AddEditRecipeViewModel(
private val repository: RecipeRepository
) : ViewModel() {
var title by mutableStateOf("")
private set
var ingredients by mutableStateOf("")
private set
var steps by mutableStateOf("")
private set
var category by mutableStateOf("")
private set
var prepMinutes by mutableStateOf(0)
private set
fun onEvent(event: RecipeEvent) {
when (event) {
is RecipeEvent.TitleChanged -> title = event.title
is RecipeEvent.IngredientsChanged -> ingredients = event.ingredients
is RecipeEvent.StepsChanged -> steps = event.steps
is RecipeEvent.CategoryChanged -> category = event.category
is RecipeEvent.PrepMinutesChanged -> prepMinutes = event.prepMinutes
RecipeEvent.SaveClicked -> saveRecipe()
}
}
private fun saveRecipe() {
viewModelScope.launch {
repository.saveRecipe(
Recipe(
title = title,
ingredients = ingredients,
steps = steps,
category = category,
prepMinutes = prepMinutes,
photoUri = null
)
)
}
}
}
And the composable itself sends events up instead of touching any state field directly. Look
closely at the TextField's onValueChange — it doesn't assign to a local
variable at all, it wraps the new text in an event and hands it to the ViewModel:
@Composable
fun AddEditRecipeScreen(
viewModel: AddEditRecipeViewModel = viewModel()
) {
Column(modifier = Modifier.padding(16.dp)) {
TextField(
value = viewModel.title,
onValueChange = { newText ->
viewModel.onEvent(RecipeEvent.TitleChanged(newText))
},
label = { Text("Recipe title") }
)
Spacer(modifier = Modifier.height(8.dp))
Button(onClick = { viewModel.onEvent(RecipeEvent.SaveClicked) }) {
Text("Save Recipe")
}
}
}
State (viewModel.title) flows down into the TextField's value.
The user's keystroke becomes an event that flows up through onEvent. The ViewModel
updates title, Compose recomposes, and the new value flows back down. The composable
itself never held a "real" copy of the title at any point — it only ever displayed whatever the
ViewModel most recently reported.
StateFlow — rather than individual mutableStateOf properties, which scales better once a screen's state has many fields collected together.