How do you compose reducers: child features inside a parent, scoping state with key paths and embedding actions?
Predict first
Two counters are mounted in one parent, plus a logger. What is the final state?
struct Reducer<State, Action> {
let run: (inout State, Action) -> Void
func callAsFunction(_ s: inout State, _ a: Action) { run(&s, a) }
func pullback<GS, GA>(state: WritableKeyPath<GS, State>, action: @escaping (GA) -> Action?) -> Reducer<GS, GA> {
Reducer<GS, GA> { gs, ga in
guard let a = action(ga) else { return }
run(&gs[keyPath: state], a)
}
}
static func combine(_ rs: Reducer...) -> Reducer {
Reducer { s, a in for r in rs { r.run(&s, a) } }
}
}
enum CounterAction { case inc, dec }
let counter = Reducer<Int, CounterAction> { s, a in s += (a == .inc ? 1 : -1) }
struct AppState { var left = 0; var right = 0; var seen = 0 }
enum AppAction { case left(CounterAction), right(CounterAction), clearLog }
let logger = Reducer<AppState, AppAction> { s, a in s.seen += 1 }
let app = Reducer.combine(
counter.pullback(state: \AppState.left, action: { (a: AppAction) -> CounterAction? in if case .left(let c) = a { c } else { nil } }),
counter.pullback(state: \AppState.right, action: { (a: AppAction) -> CounterAction? in if case .right(let c) = a { c } else { nil } }),
logger
)
var state = AppState()
for a in [AppAction.left(.inc), .left(.inc), .right(.dec), .clearLog] { app(&state, a) }
print(state.left, state.right, state.seen)
- 2 -1 4
- 1 -1 4
- 2 -1 3
Mental model. A film crew: each department (sound, lighting) has its own checklist (child reducer) and its own props (state slice); the producer hands each department only the messages meant for it and also keeps the master log.
A real app is a tree of features, and each should be written and tested alone. Composition has two parts. Scoping state: a child reducer works on its own small state (Int for a counter), and the parent holds it as a property of a larger state; a WritableKeyPath (\AppState.left) lets the parent run the child on that slice in place. Embedding actions: the parent’s action enum has a case wrapping the child’s action (.left(CounterAction)), and a function extracts the child action if there is one. Lifting a child reducer through those two functions is the pullback (in TCA, Scope); combine then runs a list of reducers in order on the same state and action. In the check two instances of the same counter reducer are mounted on left and right; they never see each other’s actions; and a parent-level logger runs on every action: the final state is left 2, right −1, three of four actions seen by the counters, four by the logger.
Design points that matter in review. The parent can observe and react to child actions (when checkout reports .finished, the parent pops the screen), which is how features coordinate without importing each other’s internals. Order matters: a parent reducer that runs before the child sees the old child state, after it the new one; pick deliberately and test it. Effects returned by a child must be mapped back into the parent’s action type, and cancellation ids need to be unique per child instance (an id from the child’s own state, not a constant, or two instances cancel each other). Optional and list children (a sheet that may be absent, a row per item) need the same lifting through an enum case or an identified collection, and the child state should be removed when the child goes away so its effects are cancelled. The check uses a pure inout function type for clarity; the real libraries add effects and dependency values to the same shape.
Each counter sees only its own actions; the logger sees all four, including the unrelated clearLog.
Common traps
- A shared constant id for effect cancellation in a child. Two instances of the same feature cancel each other’s work; key it by instance.
- Letting a child reach into parent state. It couples them; have the child report with an action and let the parent decide.
They might follow up with
- Is this only for TCA? No, any reducer architecture needs the same lift; you can write the 10 lines yourself.
- Where does navigation state live? In the parent, as optional or enum child state; presenting a child is setting it, dismissing is clearing it.
Related questions
- 15.7.2 How do you model screen state so illegal combinations cannot exist: enum with payloads versus a bag of flags?
- 15.7.1 In a reducer-style store, how do you stop a slow, stale response overwriting a newer one: cancellation…
- 15.1.2 What is unidirectional data flow, and how does a reducer make state changes predictable and testable?
- 15.2.1 What is a coordinator or router, and how does it compare with SwiftUI’s NavigationStack for deciding who owns…
← How do you model screen state so illegal combinations cannot exist…