Part five of the Redux-like state container series migrates the store from Combine to the Swift
concurrency model. The store gains @MainActor isolation and cooperative cancellation, while
old Combine reducers keep working during the migration.
The Async Store
Four changes convert the Combine store: send becomes async, the effect publisher converts to
an AsyncSequence through its values property, for await feeds resulting actions back into
the store, and @MainActor pins state access to the main thread. The cancellables set
disappears.
typealias Reducer<State, Action, Dependencies> =
(inout State, Action, Dependencies) -> AnyPublisher<Action, Never>?
@MainActor final class Store<State, Action, Dependencies>: ObservableObject {
@Published private(set) var state: State
private let dependencies: Dependencies
private let reducer: Reducer<State, Action, Dependencies>
init(
initialState: State,
reducer: @escaping Reducer<State, Action, Dependencies>,
dependencies: Dependencies
) {
self.state = initialState
self.reducer = reducer
self.dependencies = dependencies
}
func send(_ action: Action) async {
guard let effect = reducer(&state, action, dependencies) else {
return
}
for await action in effect.values {
await send(action)
}
}
}
Bridging Async Closures into Effects
Reducers still return publishers, so an async dependency needs a wrapper. SendablePublisher
runs an async closure inside Deferred { Future } and wires Combine cancellation to task
cancellation through handleEvents(receiveCancel:).
public struct SendablePublisher<Output, Failure: Error>: Publisher {
let upstream: AnyPublisher<Output, Failure>
public init(fullFill: @Sendable @escaping () async throws -> Output) where Failure == Error {
var task: Task<Void, Never>?
upstream = Deferred {
Future { promise in
task = Task {
do {
let result = try await fullFill()
try Task.checkCancellation()
promise(.success(result))
} catch {
promise(.failure(error))
}
}
}
}
.handleEvents(receiveCancel: { task?.cancel() })
.eraseToAnyPublisher()
}
public func receive<S>(subscriber: S)
where S: Subscriber, Failure == S.Failure, Output == S.Input {
upstream.subscribe(subscriber)
}
}
Dependencies then become @Sendable async closures, and a reducer wraps the call:
struct AppDependencies {
let search: @Sendable (String) async throws -> [Repo]
}
case let .search(query):
return SendablePublisher { try await dependencies.search(query) }
.replaceError(with: [])
.map(AppAction.setSearchResults)
.eraseToAnyPublisher()
Driving It from a View
The task view modifier runs an async closure when the view appears and cancels it when the
view disappears, so an in-flight search effect stops on its own once the store supports
cooperative cancellation.
List {
ForEach(store.state.searchResult, id: \.id) { repo in
Text(repo.title)
}
}
.task {
await store.send(.search(query))
}