Data Flows Down, Actions Flow Up
The confusion
SwiftUI code often starts clean, then drifts into mixed ownership:
- child views mutating data they do not own
- screens passing too many bindings
- side effects scattered across the tree
Everything still compiles, but reasoning gets harder with each feature.
What SwiftUI is actually doing
SwiftUI rebuilds view descriptions from current state.
That makes two directions explicit:
- data and configuration move from parent to child
- user intent moves from child back to parent
If both directions are modeled clearly, updates stay predictable.
The mental model
Data flows down. Actions flow up.
Downward flow:
- immutable values
- read/write bindings when ownership stays above
Upward flow:
- closures describing intent (
onDelete,onSave,onToggle)
Bindings are not ownership transfer. They are controlled write access.
A small proof
import SwiftUI
struct CounterScreen: View {
@State private var count = 0
var body: some View {
CounterRow(
count: count,
onIncrement: { count += 1 }
)
}
}
struct CounterRow: View {
let count: Int
let onIncrement: () -> Void
var body: some View {
HStack {
Text("Count: \(count)")
Spacer()
Button("Increment", action: onIncrement)
}
}
}
CounterScreen owns the state, CounterRow renders data and forwards intent.
Why this matters in real apps
- Performance: state changes stay local and intentional
- Architecture: ownership remains visible in each API
- Readability: you can scan a view and see who owns what
This pattern also makes refactoring safer: extracted views stay dumb by default.
Where this model breaks down
It is not a strict law.
Cases that need adaptation:
- very deep trees where dependency injection via environment is cleaner
- local ephemeral state (focus, temporary UI flags) inside leaf views
- advanced list row editing where value + callback can become verbose
Even then, ownership should still be explicit.
One sentence to remember
If a child view decides behavior, pass an action; if it reflects state, pass data.