This follow-up to the functional-core article makes the split opinionated: pure reducers form
the functional core, a @MainActor store forms the imperative shell, and Middleware functions
carry the side effects. Each layer tests at its own level.
Pure Reducers
A reducer takes state and action and returns new state, nothing else. Tests need no mocks.
typealias Reducer<State, Action> = (State, Action) -> State
let timerReducer: Reducer<TimerState, TimerAction> = { state, action in
var state = state
switch action {
case .start: state.start = .now
case .finish: state.end = .now
case .reset:
state.start = nil
state.end = nil
}
return state
}
func testStart() {
let state = TimerState(goal: 13 * 3600)
let newState = timerReducer(state, .start)
XCTAssertNotNil(newState.start)
}
Middleware for Side Effects
A middleware intercepts an action, runs async work against the injected dependencies, and returns an optional follow-up action to feed back into the store. Mocking the dependencies makes it integration-testable.
typealias Middleware<State, Action, Dependencies> =
(State, Action, Dependencies) async -> Action?
let timerMiddleware: Middleware<TimerState, TimerAction, TimerDependencies> = { state, action, dependencies in
switch action {
case .share:
guard let start = state.start else {
return .setSharingStatus(.notShared)
}
do {
try await dependencies.share(start, state.end)
return .setSharingStatus(.shared)
} catch {
return .setSharingStatus(.notShared)
}
default:
return nil
}
}
The Store Ties It Together
The store reduces the action first, then runs every middleware in a TaskGroup, feeding
returned actions back through send. @MainActor guards the state, and the task group gives
cooperative cancellation of side effects for free.
@MainActor public final class Store<State, Action, Dependencies>: ObservableObject {
@Published public private(set) var state: State
private let reducer: Reducer<State, Action>
private let dependencies: Dependencies
private let middlewares: [Middleware<State, Action, Dependencies>]
public init(
initialState state: State,
reducer: @escaping Reducer<State, Action>,
dependencies: Dependencies,
middlewares: [Middleware<State, Action, Dependencies>] = []
) {
self.reducer = reducer
self.state = state
self.dependencies = dependencies
self.middlewares = middlewares
}
public func send(_ action: Action) async {
state = reducer(state, action)
await withTaskGroup(of: Optional<Action>.self) { [state, dependencies] group in
for middleware in middlewares {
group.addTask {
await middleware(state, action, dependencies)
}
}
for await case let action? in group {
await send(action)
}
}
}
}
A view creates the store with @StateObject and dispatches with
Task { await store.send(.start) } from buttons; the state is read-only from outside, so every
mutation flows through an action.