What is unidirectional data flow, and how does a reducer make state changes predictable and testable?
Predict first
A pure reducer is fed three actions. What is the final state?
struct State: Equatable { var count = 0; var log: [String] = [] }
enum Action { case inc, dec, reset }
func reduce(_ s: inout State, _ a: Action) {
switch a {
case .inc: s.count += 1
case .dec: s.count -= 1
case .reset: s.count = 0
}
s.log.append("\(a)")
}
var st = State()
for a in [Action.inc, .inc, .dec] { reduce(&st, a) }
print(st.count, st.log)
- 1 ["inc", "inc", "dec"]
- 2 ["inc", "inc"]
- 0 []
Mental model. A bank ledger: balances are never edited directly, you append numbered transactions, and one clerk (the reducer) applies each in order. Anyone can reconstruct any balance by replaying the ledger.
Unidirectional data flow means state changes travel in one loop: the view renders from a single state; user input and system events become actions (plain values like .incrementTapped or .responseReceived(result)); a reducer, a function (inout State, Action) -> Effect, is the only place the state changes; and any side effect (a network call, a timer) is returned as an effect that eventually feeds new actions back in. Nothing mutates state from the side, and the view never edits the model directly. The shape is the same in Redux, Elm, TCA and MVI, and in SwiftUI it fits naturally because the view is already a function of state. The pure core, reduce(&state, action), is a plain Swift function: the same state and action always produce the same result, so you can test whole user flows by feeding a list of actions and asserting on the final state (inc, inc, dec from zero gives 1, with a log of three entries), with no mocks and no UI.
Benefits: one source of truth (no two views disagree), predictability (every change has a name and a place; debugging means reading the action log), time travel and replay (store the actions and rebuild the state; reproduce a bug from a log), exhaustive handling (a switch over an action enum makes the compiler tell you about unhandled cases), and testability. Costs: boilerplate (an action for every event, a state struct for every screen), the discipline of keeping effects out of the reducer, awkward fit for naturally local UI state (a text field’s cursor or a scroll position, which should stay in @State), large single states that cause many views to re-evaluate unless you scope them, and, with a framework like TCA, a dependency and a style that the whole team must share. Compose by scoping: a parent state contains child states, and the parent reducer forwards child actions to the child reducers. Keep state serialisable and small, model navigation as state too (an optional destination), and use dependency injection for effects so tests can replace them. Adopt it where complex, shared state and testability matter, not for every screen.
Each action is applied in order by the same function, giving a count of 1 and a log of all three actions.
Common traps
- Doing side effects inside the reducer. It stops being pure and untestable; return an effect instead.
- Putting every tiny UI detail (a cursor position) into global state. Keep purely local view state local.
They might follow up with
- How do you test a flow? Feed a sequence of actions to the reducer and assert on the resulting state.
- What does scoping do? Lets a child feature own a slice of the parent’s state and its own actions.
Related questions
- 15.1.1 How do MVC, MVVM, MVP, VIPER, RIBs and Clean compare, and which would you choose?
- 15.2.1 What is a coordinator or router, and how does it compare with SwiftUI’s NavigationStack for deciding who owns…
- 15.2.2 How do you do dependency injection in Swift, and when do you need a container or framework?
← How do MVC, MVVM, MVP, VIPER, RIBs and Clean compare, and which would…