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:

DirectionWhat travelsWho initiates it
DownState (the current, immutable snapshot of data)The ViewModel exposes it; the composable only reads it
UpEvents (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.

Note: This is correct, real Compose/Kotlin code — but this website's own project doesn't run a live Android Studio build, so you won't see it actually render on this page.
Next up: StateFlow & collectAsState covers exposing state as a proper reactive stream — StateFlow — rather than individual mutableStateOf properties, which scales better once a screen's state has many fields collected together.