One owner, data down, events up
Imagine a shared shopping list on the fridge. Everyone reads from it, and to change it you go to the fridge and write on it. Nobody keeps a private copy in their pocket, because pocket copies go out of date the moment someone else changes the list.
In an app:
- One owner keeps each piece of state, usually the screen’s ViewModel or a parent view.
- Data flows down: the owner passes the current values to the views that show them.
- Events flow up: when the user taps ”+”, the stepper doesn’t change a number of its own. It tells the owner “increase item 7”, and the owner updates the state.
- The new state flows down again, and every view showing it updates together.
Anything you can compute from the state, like the total or the badge count, is computed every time, never stored separately. A computed value can’t be out of date.
Unidirectional data flow (UDF) structures state ownership so that every mutable value has a single source of truth, views are functions of state, and changes happen only through events handled by the owner. It’s the common core of Flux/Redux, Elm, MVI, The Composable Architecture, and the official guidance for both Compose (“state down, events up”, state hoisting) and SwiftUI (@State owned by one view, @Binding and @Observable models elsewhere). The benefits are consistency (no drift between copies), traceability (one place to log or debug every change), and testability (owners are plain objects; views are previews of state). The two disciplines that make it work: hoist state to the lowest common owner of all its readers and writers, and derive everything derivable.
@Observable
final class CartModel {
private(set) var items: [CartItem] = [] // the only source of truth
var total: Money { items.reduce(.zero) { $0 + $1.price * $1.quantity } } // derived
var badgeCount: Int { items.reduce(0) { $0 + $1.quantity } } // derived
func setQuantity(_ quantity: Int, for id: ItemId) { // events come here
guard let i = items.firstIndex(where: { $0.id == id }) else { return }
if quantity == 0 { items.remove(at: i) } else { items[i].quantity = quantity }
}
}
struct CartRow: View {
let item: CartItem // data down
let onQuantityChange: (Int) -> Void // events up
var body: some View {
Stepper("\(item.name): \(item.quantity)", value: Binding(
get: { item.quantity },
set: { onQuantityChange($0) }
), in: 0...99)
}
}
struct CartScreen: View {
let cart: CartModel
var body: some View {
List(cart.items) { item in
CartRow(item: item) { cart.setQuantity($0, for: item.id) }
}
Text("Total: \(cart.total.formatted)")
}
}data class CartState(val items: List<CartItem> = emptyList()) {
val total: Money get() = items.fold(Money.ZERO) { acc, it -> acc + it.price * it.quantity } // derived
val badgeCount: Int get() = items.sumOf { it.quantity } // derived
}
class CartViewModel : ViewModel() {
private val _state = MutableStateFlow(CartState()) // the only source of truth
val state: StateFlow<CartState> = _state.asStateFlow()
fun setQuantity(id: ItemId, quantity: Int) = _state.update { s -> // events come here
s.copy(items = if (quantity == 0) s.items.filterNot { it.id == id }
else s.items.map { if (it.id == id) it.copy(quantity = quantity) else it })
}
}
@Composable
fun CartRow(item: CartItem, onQuantityChange: (Int) -> Unit) { // data down, events up
QuantityStepper(value = item.quantity, onValueChange = onQuantityChange)
}
@Composable
fun CartScreen(viewModel: CartViewModel) {
val state by viewModel.state.collectAsStateWithLifecycle()
LazyColumn {
items(state.items, key = { it.id.value }) { item ->
CartRow(item) { viewModel.setQuantity(item.id, it) }
}
}
Text("Total: ${state.total}")
}// React: the cart owns the items; rows get values and callbacks
function CartScreen() {
const [items, setItems] = useState<CartItem[]>([]); // the only source of truth
const total = items.reduce((sum, i) => sum + i.price * i.quantity, 0); // derived on render
const badgeCount = items.reduce((n, i) => n + i.quantity, 0); // derived on render
const setQuantity = (id: ItemId, quantity: number) => // events come here
setItems((prev) => quantity === 0
? prev.filter((i) => i.id !== id)
: prev.map((i) => (i.id === id ? { ...i, quantity } : i)));
return (
<>
{items.map((item) => (
<CartRow key={item.id} item={item} onQuantityChange={(q) => setQuantity(item.id, q)} />
))}
<p>Total: {formatMoney(total)}</p>
</>
);
}
function CartRow({ item, onQuantityChange }: { item: CartItem; onQuantityChange(q: number): void }) {
return <QuantityStepper value={item.quantity} onChange={onQuantityChange} />; // data down, events up
}itemsis stored in one place. Nothing else stores quantities.totalandbadgeCountare computed fromitems, so they’re always right. There’s no “remember to update the total” code to forget.CartRowgets the item (data down) and a function to call (event up). It doesn’t decide anything; it asks the owner to change the quantity, and shows whatever comes back.- When the server applies a coupon and returns new items, the owner replaces
itemsonce, and every row, the total and the badge update together.
Tap the line with the bug.
struct CartRow: View {
let item: CartItem
let onQuantityChange: (Int) -> Void
@State private var quantity: Int
init(item: CartItem, onQuantityChange: @escaping (Int) -> Void) {
self.item = item
self.onQuantityChange = onQuantityChange
_quantity = State(initialValue: item.quantity)
}
var body: some View {
Stepper("Qty: " + String(quantity), value: $quantity, in: 0...99)
.onChange(of: quantity) { _, new in onQuantityChange(new) }
}
}@Composable
fun CartRow(item: CartItem, onQuantityChange: (Int) -> Unit) {
var quantity by remember { mutableIntStateOf(item.quantity) }
QuantityStepper(
value = quantity,
onValueChange = {
quantity = it
onQuantityChange(it)
},
)
}function CartRow({ item, onQuantityChange }: CartRowProps) {
const [quantity, setQuantity] = useState(item.quantity);
return (
<QuantityStepper
value={quantity}
onChange={(q) => {
setQuantity(q);
onQuantityChange(q);
}}
/>
);
}Hint: The row's local quantity is initialized from item.quantity. What happens to it when item.quantity changes later?
The row keeps its own copy of the quantity, initialized from the item only the first time the row is created. @State(initialValue:), remember { … } and useState(initial) all ignore later values of their initializer. When the server applies a coupon and changes the quantity to 1, or another device updates the cart, the owner’s state changes, the row receives the new item, and keeps showing its stale local copy. Now the row says 3 and the total is computed from 1.
There should be one source of truth. Show the owner’s value and send changes up:
struct CartRow: View {
let item: CartItem
let onQuantityChange: (Int) -> Void
var body: some View {
Stepper("Qty: " + String(item.quantity), value: Binding(
get: { item.quantity },
set: { onQuantityChange($0) }
), in: 0...99)
}
}@Composable
fun CartRow(item: CartItem, onQuantityChange: (Int) -> Unit) {
QuantityStepper(value = item.quantity, onValueChange = onQuantityChange)
}function CartRow({ item, onQuantityChange }: CartRowProps) {
return <QuantityStepper value={item.quantity} onChange={onQuantityChange} />;
}Local state is fine for things nobody else cares about (is this row’s menu open?) and for drafts the user hasn’t committed yet (a text field before Save), as long as it’s clearly a draft and not a second copy of the truth.
Predict the output
The cart badge on the tab bar and the cart screen both need the item count. Where should the cart state live?
Hoist state to the lowest common owner of everything that reads or changes it. The badge and the screen are siblings, so neither can own it; a shared cart model (injected into both, or owned by the app-level state) is the owner, and both derive the count from it. A separate counter in the tab bar is exactly the copy that drifted to 3 in the story above.
- Single source of truth per piece of state; views render state; changes only via events handled by the owner.
- Hoist to the lowest common owner of all readers and writers; derive totals, counts and flags instead of storing them.
- Local state initialized from a parameter (
@State(initialValue:),remember {},useState(prop)) ignores later updates: a stale second source. Keep local state for UI-only concerns and explicit drafts. - Shared core of Redux/Elm/MVI/TCA and the Compose and SwiftUI guidance.
Check this part
In unidirectional data flow, what does a child view do when the user changes a value?
Why compute the total instead of storing it?
What's wrong with remember { mutableStateOf(item.quantity) } in a row?