One store holds the state, views send actions, and a pure reduce function computes the next state. The loop runs one way, which makes the system predictable, testable, and easy to debug.
The Store
The store is generic over state and action. State is private(set), so send is the only door
in, and @Observable lets SwiftUI track reads.
import Observation
@Observable final class Store<State, Action> {
private(set) var state: State
private let reduce: (State, Action) -> State
init(initialState state: State, reduce: @escaping (State, Action) -> State) {
self.state = state
self.reduce = reduce
}
func send(_ action: Action) {
state = reduce(state, action)
}
}
State, Action, Reduce
State is a struct, action is an enum, and the reduce function is the only place that mutates. Compose one reduce function per feature module rather than one for the whole app.
struct ShopState: Equatable {
var products: [String] = []
}
enum ShopAction: Equatable {
case add(String)
case remove(String)
}
let reduce: (ShopState, ShopAction) -> ShopState = { state, action in
var newState = state
switch action {
case let .add(product): newState.products.append(product)
case let .remove(product): newState.products.removeAll { $0 == product }
}
return newState
}
The view reads store.state and sends actions; it can never write state directly.
List(store.state.products, id: \.self) { product in
Text(verbatim: product)
.swipeActions {
Button(role: .destructive) {
store.send(.remove(product))
} label: {
Label("Delete", systemImage: "trash")
}
}
}
Why the Shape Pays Off
- Tests: the reduce function is pure over value types, so a test is build state, apply action, assert on the result. No mocks.
- Previews: each
#Previewconstructs a store with a different initial state, empty list or full list, again without mock protocols. - Debugging: every mutation passes through reduce, so one log line there captures the whole history, and the state struct encodes to JSON for inspection or restore.
- Performance: prefer a store per feature. One app-wide store refreshes the whole view hierarchy on every small change.
The author's swift-unidirectional-flow package implements this with concurrency support; treat it as inspiration rather than a dependency for core app features.