Lesson 05 / 14
Unidirectional Data Flow
In unidirectional data flow, events go up, state comes down, and the UI does not write state directly. The lesson measures which tier gives the answer when the new state built by a reducer that handles an event is read.
Published Updated
In this lesson
- Measures that the reducer that handles an event builds the new state in memory and that the read comes from memory
- Shows that a read made before the event is handled comes from the default
- Classifies that when the event order changes, the value of the same read changes but its origin does not
Contents
In the previous lesson the UI collected a changing value from a flow. How the value enters the flow is a separate question: who writes it? In unidirectional data flow the UI does not write state. The user’s tap becomes an event, the event goes to the state holder, the holder builds the new state, and the UI reads the new state. The question is again the same: where does the answer to this read come from?
The measuring tool is the core of the previous lessons; we repeat it unchanged.
// Origin.kt — classifies which tier a read's answer came from
class Tier(val origin: String, private val entries: Map<String, String>) {
init {
require(origin in listOf("memory", "disk", "network")) { "invalid tier: $origin" }
}
fun lookup(key: String): String? = entries[key]
}
class Read(val label: String, val key: String, chain: List<Tier>, fallback: String) {
private val hit: Tier? = chain.firstOrNull { it.lookup(key) != null }
val value: String = hit?.lookup(key) ?: fallback
val origin: String = hit?.origin ?: "default"
fun show() {
println("$label: key=$key | value=$value -> $origin")
}
}
fun countOrigins(reads: List<Read>) {
for (name in listOf("memory", "disk", "network", "default")) {
println("$name: ${reads.count { it.origin == name }}")
}
}
The Cycle: Events Go Up, State Comes Down
On the platform, an event is usually written as a sealed type, and the view model receives it through a single entry function. The entry function produces the new state from the current state and writes it to the flow. The UI does only two things: it draws the state and sends the event.
// Platform code: CartViewModel
import androidx.lifecycle.ViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update
sealed interface CartEvent {
data class QuantityChanged(val quantity: Int) : CartEvent
object Cleared : CartEvent
}
class CartViewModel : ViewModel() {
private val _state = MutableStateFlow(CartUiState())
val state: StateFlow<CartUiState> = _state
fun onEvent(event: CartEvent) {
_state.update { current ->
when (event) {
is CartEvent.QuantityChanged -> current.copy(quantity = event.quantity)
CartEvent.Cleared -> CartUiState()
}
}
}
}
// Platform code: CartScreen
@Composable
fun CartScreen(viewModel: CartViewModel) {
val state by viewModel.state.collectAsState()
Button(onClick = { viewModel.onEvent(CartEvent.QuantityChanged(state.quantity + 1)) }) {
Text("Count: ${state.quantity}")
}
}
These two pieces are platform code, and here we make no claim about output, timing or file paths. The model builds this rule of the platform; the model is not the platform itself.
In M31/K03 we built the UI as a tree and measured which direction data travels in that tree. Here the question is not the direction of the tree: we ask which tier gives the answer when the state coming down is read. Direction is not a category in this course; the origin of the read is what gets measured.
In the model, a reducer is a function that takes two inputs: the current state and the event. Its return value is the new state, overwritten with the event’s values. applyAll handles the events in order. stateReads puts the new state in the memory tier and builds four reads.
// Reducer.kt — the event produces the new state from the current state
class Event(val name: String, val values: Map<String, String>)
fun reduce(state: Map<String, String>, event: Event): Map<String, String> =
state + event.values
fun applyAll(initial: Map<String, String>, events: List<Event>): Map<String, String> =
events.fold(initial) { current, event -> reduce(current, event) }
fun stateReads(state: Map<String, String>): List<Read> {
val chain = listOf(Tier("memory", state))
return listOf(
Read("count", "cart.count", chain, "0"),
Read("total", "cart.total", chain, "0"),
Read("discount", "cart.discount", chain, "none"),
Read("note", "cart.note", chain, "empty"),
)
}
The Event Is Handled, the State Is Built in Memory
Let the cart start with a count of one. The user raises the count to two, and then the total is computed. Two events pass through the reducer and the new state is read.
// Main.kt — two events are handled, the new state is built in memory
fun main() {
val initial = mapOf("cart.count" to "1")
val events = listOf(
Event("set-count", mapOf("cart.count" to "2")),
Event("compute-total", mapOf("cart.total" to "240")),
)
val state = applyAll(initial, events)
val reads = stateReads(state)
reads.forEach { it.show() }
countOrigins(reads)
}
count: key=cart.count | value=2 -> memory total: key=cart.total | value=240 -> memory discount: key=cart.discount | value=none -> default note: key=cart.note | value=empty -> default memory: 2 disk: 0 network: 0 default: 2
The count and the total were read from the state the reducer built: two reads are memory. The discount and the note were not written by any event; because the chain’s only tier is memory, they fell to the default. That is, a freshly built state makes the read memory, but a field that was not built is still the default.
AA1. The model builds the state as a plain map and reduces the UI sending an event to a list element. On the platform events arrive over time and the flow redraws the UI on every change; the model has no drawing.
The Event Order Changes, the Origin Does Not
What happens if the same two events arrive in reverse order? Let the count be set first to five and then to two; then let the same events be handled in reverse order. Because the reducer overwrites, the last event wins.
// Main.kt — the event order changes the value, the origin stays the same
fun main() {
val first = Event("count-five", mapOf("cart.count" to "5"))
val second = Event("count-two", mapOf("cart.count" to "2"))
val inOrder = stateReads(applyAll(mapOf(), listOf(first, second)))
val reversed = stateReads(applyAll(mapOf(), listOf(second, first)))
println("order: five, two")
inOrder[0].show()
println("order: two, five")
reversed[0].show()
}
order: five, two count: key=cart.count | value=2 -> memory order: two, five count: key=cart.count | value=5 -> memory
The same read gave two values: 2 and 5. Both came from memory. The rule sees the tier, not the value: when the order changed, the value changed and the origin did not.
AA2. The model builds the “last event wins” rule with overwriting. On the platform, two events that write the same field can be merged, ordered or dropped; this decision belongs to the app and is absent from the model.
The Bounding Measurement
The limit is the moment the event has not yet been handled. A field read while the state is empty is compared with the same field read after the event is handled.
// Main.kt — a read before the event is handled gives the default
fun main() {
val before = stateReads(mapOf())
println("before events:")
countOrigins(before)
val after = stateReads(
applyAll(
mapOf(),
listOf(
Event("set-count", mapOf("cart.count" to "2")),
Event("add-discount", mapOf("cart.discount" to "code-10")),
),
)
)
println("after events:")
after.forEach { it.show() }
countOrigins(after)
}
before events: memory: 0 disk: 0 network: 0 default: 4 after events: count: key=cart.count | value=2 -> memory total: key=cart.total | value=0 -> default discount: key=cart.discount | value=code-10 -> memory note: key=cart.note | value=empty -> default memory: 2 disk: 0 network: 0 default: 2
Before the events were handled, four of the four reads gave the default. After two events, the count and the discount came from memory; the total and the note are still the default. It is the event that writes the state; as long as no event arrives, what the UI will read is not in the chain but is the default that the code wrote.
AA3. The model builds the empty state with an empty map. On the platform the initial state is usually an initial value that comes before loading, as we saw in the Observable State lesson.
Summary
- This lesson’s origin: memory. The reducer that handled the event built the new state in memory, and after two events the count and the total were read from the memory tier.
- The lesson’s measure is whether the read came from the newly built state or from a field that was never written. Before the events were handled, memory was 0 and default 4; after two events, memory was 2 and default 2.
- When the event order changed, the same read gave 2 and 5, but the origin of both is memory: the rule sees the tier, not the value.
- The UI does not write state; the event writes it. A field for which no event arrives stays the default.
- Direction is not a category in this course; what is measured is which tier gives the answer.
Next Step
So far we have built the tiers of the chain ourselves. In a real app, who builds the chain? The next lesson, Dependency Injection, measures how the tiers an object needs are handed in from outside and where the answer comes from in the production chain.
To keep your progress and take notes, Log in
My notes
Log in to take notes.