Skip to content

Redux-like Store with Swift Concurrency (SwiftUI)

A SwiftUI guide for coding agents. Also covers unidirectional flow async await, store reducer mainactor, combine to swift concurrency migration, publisher values asyncsequence, async effect cancellation.

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))
}

Use this guidance in your coding agent

Install the Better Design MCP once. Your agent then loads this page with one call.

get-swiftui-guide({ topic: "swiftui-redux-like-state-container-part5" })
claude mcp add --scope user better-design --transport http https://better-design.com/api/mcp --header "Authorization: Bearer <YOUR_API_KEY>"
Browse related design systems