Skip to content
Commitwise
Menu
Design Patterns

Unidirectional data flow

Keep each piece of state in one owner, pass it down and send events up, derive values instead of storing copies, hoist state so siblings agree, and use a store with a reducer and effects when state is shared across screens, in Swift (SwiftUI), Kotlin (Compose) and TypeScript (React).

intermediate

The core idea with one simple example.Step by step, with more examples and exercises.A cheat sheet: key APIs, gotchas and trade-offs.Internals, trade-offs and production pitfalls.

The cart screen shows each item with a quantity stepper, and a total at the bottom. The stepper keeps its own quantity, so it can update instantly. The total is kept in a separate variable, updated whenever “something changes”. The badge on the Cart tab counts items with its own counter.

Users report that the badge says 3 while the cart shows 2 items, that the total doesn’t change when they remove an item, and that after a coupon is applied on the server, the steppers still show the old quantities. Three copies of the same fact, each updated by a different path, have drifted apart.

Unidirectional data flow fixes this with two rules: every piece of state has one owner, and data flows down from the owner while events flow up to it. Everything else is computed from that one source.

New to some of these? Open what you need, or skip ahead.

Basics:What is UI state?

State is the data the screen shows that can change over time: the items in the cart, whether something is loading, the text in a field. When state changes, declarative UI frameworks (SwiftUI, Jetpack Compose, React) redraw the parts of the screen that depend on it.

Basics:What is a ViewModel?

A ViewModel holds a screen’s state and logic outside the UI code, so the state survives things like screen rotation and can be tested without a UI. The MVC, MVP, MVVM and MVI tutorial covers it.

Part Not started

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
}
  • items is stored in one place. Nothing else stores quantities.
  • total and badgeCount are computed from items, so they’re always right. There’s no “remember to update the total” code to forget.
  • CartRow gets 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 items once, and every row, the total and the badge update together.
Spot the bugThe stepper ignores changes from the server

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);
      }}
    />
  );
}

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?

  • 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

  1. In unidirectional data flow, what does a child view do when the user changes a value?

  2. Why compute the total instead of storing it?

  3. What's wrong with remember { mutableStateOf(item.quantity) } in a row?

Each piece of state has exactly one owner. The owner passes values down; children send events up and never keep their own copy. Values that can be computed (totals, counts, flags) are derived, not stored. Hoist state to the closest common owner of everything that reads or changes it.

Part Not started

A store: reducer, actions and effects

For one screen, a ViewModel that owns the state is enough. When many screens share the same data (the cart, the logged-in user, unread messages), apps often use a store: one object that holds that state for the whole app.

A store works like a bank:

  • You can’t change your balance directly. You send a request: “deposit 100”.
  • The bank follows fixed rules to compute the new balance from the old one and the request. In code, this rule is a function called a reducer: (state, action) → new state.
  • Everyone who reads the balance sees the same number, and there’s a record of every request.

Work that takes time or talks to the outside world (calling the server) is an effect. It runs outside the reducer and, when it’s done, sends a new action with the result (“coupon applied”, “coupon failed”).

The store form of UDF (Redux, TCA, MVI stores, Elm) adds: immutable state, actions as the only input, a pure reducer (State, Action) -> State (plus, in TCA and Elm, returned effects), and effects that perform I/O and feed results back as actions. Purity makes the reducer trivially testable and replayable; immutability makes change detection cheap (reference inequality) and correct. The costs: boilerplate, indirection for simple screens, and a single global store that becomes a dumping ground. Scope stores per feature, keep ephemeral UI state local, and don’t put non-serializable things (view references, closures) in state. One-off events (navigate, show a toast) are a known pain point: model them as state that’s consumed and cleared, or as effects, never as state that replays on recomposition.

struct CartState: Equatable {
    var items: [CartItem] = []
    var coupon: CouponStatus = .none
}

enum CartAction {
    case setQuantity(ItemId, Int)
    case applyCoupon(String)
    case couponResponse(Result<[CartItem], CouponError>)
}

// Pure: no network, no clock, no randomness. Easy to test
func cartReducer(_ state: inout CartState, _ action: CartAction) -> Effect<CartAction> {
    switch action {
    case let .setQuantity(id, quantity):
        state.items = state.items.compactMap { $0.id == id ? (quantity == 0 ? nil : $0.with(quantity: quantity)) : $0 }
        return .none
    case let .applyCoupon(code):
        state.coupon = .applying
        return .run { api in .couponResponse(await api.applyCoupon(code)) }   // effect, outside the reducer
    case let .couponResponse(.success(items)):
        state.items = items
        state.coupon = .applied
        return .none
    case let .couponResponse(.failure(error)):
        state.coupon = .failed(error.message)
        return .none
    }
}
data class CartState(val items: List<CartItem> = emptyList(), val coupon: CouponStatus = CouponStatus.None)

sealed interface CartAction {
    data class SetQuantity(val id: ItemId, val quantity: Int) : CartAction
    data class ApplyCoupon(val code: String) : CartAction
    data class CouponResponse(val result: Result<List<CartItem>>) : CartAction
}

// Pure: returns the next state; effects are handled by the store
fun reduce(state: CartState, action: CartAction): CartState = when (action) {
    is CartAction.SetQuantity -> state.copy(items = state.items.mapNotNull {
        if (it.id != action.id) it else if (action.quantity == 0) null else it.copy(quantity = action.quantity)
    })
    is CartAction.ApplyCoupon -> state.copy(coupon = CouponStatus.Applying)
    is CartAction.CouponResponse -> action.result.fold(
        onSuccess = { state.copy(items = it, coupon = CouponStatus.Applied) },
        onFailure = { state.copy(coupon = CouponStatus.Failed(it.message.orEmpty())) },
    )
}

class CartStore(private val api: CartApi, private val scope: CoroutineScope) {
    private val _state = MutableStateFlow(CartState())
    val state: StateFlow<CartState> = _state.asStateFlow()

    fun send(action: CartAction) {
        _state.update { reduce(it, action) }
        if (action is CartAction.ApplyCoupon) scope.launch {                 // effect
            send(CartAction.CouponResponse(runCatching { api.applyCoupon(action.code) }))
        }
    }
}
interface CartState { items: CartItem[]; coupon: CouponStatus }

type CartAction =
  | { type: "setQuantity"; id: ItemId; quantity: number }
  | { type: "applyCoupon"; code: string }
  | { type: "couponApplied"; items: CartItem[] }
  | { type: "couponFailed"; message: string };

// Pure: returns a new state object, never mutates the old one
function cartReducer(state: CartState, action: CartAction): CartState {
  switch (action.type) {
    case "setQuantity":
      return {
        ...state,
        items: action.quantity === 0
          ? state.items.filter((i) => i.id !== action.id)
          : state.items.map((i) => (i.id === action.id ? { ...i, quantity: action.quantity } : i)),
      };
    case "applyCoupon":
      return { ...state, coupon: { kind: "applying" } };
    case "couponApplied":
      return { ...state, items: action.items, coupon: { kind: "applied" } };
    case "couponFailed":
      return { ...state, coupon: { kind: "failed", message: action.message } };
  }
}

// Effect: runs outside the reducer and reports back with actions
async function applyCoupon(code: string, dispatch: (a: CartAction) => void, api: CartApi) {
  dispatch({ type: "applyCoupon", code });
  try {
    dispatch({ type: "couponApplied", items: await api.applyCoupon(code) });
  } catch (e) {
    dispatch({ type: "couponFailed", message: String(e) });
  }
}

Production puzzles

Predict the output

Kotlin: data class CartState(val items: MutableList<CartItem>). An event handler does _state.value.items.add(item). Does the Compose screen show the new item?

Predict the output

An MVI ViewModel stores navigateToCheckout = true in its state after a successful validation, and the screen navigates when it sees true. The user goes to checkout, then presses Back. What happens?

Swift note

With @Observable classes, SwiftUI tracks which properties each view reads, so mutating a property in place (items.append(x) on an array property) is observed: the property’s setter runs, because arrays are value types. The trap in Swift is the opposite one: putting a class inside the state (an array of class instances) and mutating a field of one element, which doesn’t touch the observed property at all. Keep state as structs.

When a store is too much

Not everything belongs in a global store. Text being typed, a scroll position, whether a menu is expanded: keep these local to the view. A store pays off for state that several screens read and change, that must survive navigation, or whose changes you want to log, replay or test as a sequence. Splitting stores per feature (TCA’s scoped stores, one store per feature module) avoids one giant state type that every change recompiles and every screen observes.

Exerciseeasy

Find the second source of truth

A profile screen shows the user’s name in the header and in an edit field. The edit field keeps its own name copy and updates the header when the user taps Save. A background refresh can update the profile from the server at any time. Describe two bugs this can cause and how to fix them with UDF.

›

What happens when the server changes the name while the user isn’t editing? And while they are?

SolutionTry it yourself first
  • Bug 1: a server refresh updates the header but not the field’s copy, so the field shows the old name, and pressing Save writes the old name back, undoing the server change.
  • Bug 2: the header and field can show different names after a failed save.

Fix: the profile (in a ViewModel or store) is the one source of truth; the header shows it. The edit field holds an explicit draft, created when editing starts, from the current profile. Save sends an event; on success the owner updates the profile, and the draft is discarded. If the profile changes on the server while a draft is open, the screen can show “This profile was updated” instead of silently overwriting it.

  • Store form: immutable state, actions, pure reducer, effects that feed results back as actions (Redux, TCA, Elm, MVI).
  • Replace state, never mutate in place: StateFlow/mutableStateOf/React compare references or equality; mutated collections aren’t observed.
  • One-off events (navigation, toasts) as plain state replay; consume and clear, or use effects/channels.
  • Scope stores per feature; keep ephemeral UI state local; no closures or view references in state.

Check this part

  1. What is a reducer?

  2. Why doesn't items.add(x) on a list inside a StateFlow's value update the UI?

  3. Where should a network call triggered by an action run?

When many screens share state, put it in a store: state is immutable, events (actions) go to a reducer that returns the next state, and side effects such as network calls run outside the reducer and report back with new actions. Replace state, never mutate it in place, or observers miss the change.

Key takeaways

  • Every piece of state has one owner.
  • Data flows down to views; events flow up to the owner.
  • Compute totals and counts instead of storing them.
  • Don’t keep a local copy of a value that someone else owns.
  • Single source of truth, hoisted to the lowest common owner, everything else derived.
  • Stores add immutable state, pure reducers and effects for shared state.
  • Replace state instead of mutating it; handle one-off events explicitly.
  • Scope stores per feature and keep ephemeral UI state local.

Finished this page?Nice work, this page is complete!

Mark it as done to track your progress. It's saved in this browser, no account needed.

Its cards are now in your Bites review ⚡
Esc

Type to search the whole site.