AsyncViewModel is a Swift Package that reimagines the original Combiner pattern using pure Swift Concurrency.
It gives you the familiar action → mutation → state pipeline without touching Combine publishers or third-party dependencies, allowing you to model view logic entirely with async/await.
flowchart TD
View --> Action
Action --> VM["AsyncViewModel"]
VM --> State
State --> View
- 🧠 Protocol-based API – adopt
AsyncViewModeland provide your ownState,Action, and optionalMutationtypes. - 🔁 Buffered actions –
send(_:)can be called before the pipeline starts; actions are replayed once observation begins. - 📡 Async-friendly state – state updates are broadcast through a built-in
CurrentValueRelay, giving youAsyncStreamaccess viastates(). - 🧪 Testable by design – the package ships with tests that exercise the
whole flow using Swift's new lightweight
Testingmodule.
- Swift 6.0 (tools version 6.2)
- Platforms: macOS 13, iOS 17, tvOS 15, watchOS 10
Add the package to your Package.swift:
.package(url: "https://github.com/velos/AsyncViewModel", branch: "main")and declare the dependency where you need it:
.target(
name: "YourFeature",
dependencies: [
.product(name: "AsyncViewModel", package: "AsyncViewModel")
]
)import AsyncViewModel
import Observation
@Observable
final class CounterViewModel: AsyncViewModel {
struct State: Sendable {
var count: Int = 0
var isLoading = false
}
enum Action: Sendable {
case increment
case loadPreset
}
enum Mutation: Sendable {
case setCount(Int)
case setLoading(Bool)
}
let initialState = State()
func mutate(action: Action) async -> MutationStream {
switch action {
case .increment:
return .just(.setCount(currentState.count + 1))
case .loadPreset:
return MutationStream { continuation in
continuation.yield(.setLoading(true))
Task {
try? await Task.sleep(nanoseconds: 250_000_000)
continuation.yield(.setCount(42))
continuation.yield(.setLoading(false))
continuation.finish()
}
}
}
}
func reduce(state: State, mutation: Mutation) -> State {
var state = state
switch mutation {
case let .setCount(value):
state.count = value
case let .setLoading(flag):
state.isLoading = flag
}
return state
}
}Consume the stream anywhere you like:
let viewModel = CounterViewModel()
Task {
for await state in viewModel.states() {
print("Count:", state.count)
}
}
viewModel.send(.increment)
viewModel.send(.loadPreset)Open the Documentation.docc catalog in Xcode to read the built-in guides:
- QuickStart – build a counter in a few minutes.
- SwiftUIIntegration – wire actions and state into Observation-backed views.
- TestingAsyncViewModel – assert behaviour with the Swift
Testingpackage.
send(_:)– push an action into the pipeline. Calls that arrive before someone subscribes are buffered and replayed as soon as the pipeline starts.mutate(action:) async -> MutationStream– perform side effects and emit the resulting mutations. Return.empty()when there is nothing to do, or.just(_:)for quick one-off mutations.reduce(state:mutation:) -> State– fold the previous state with a mutation and return the next value.transform(action:),transform(mutation:),transform(state:)– override these hooks to reshape input (for example with swift-async-algorithms), merge additional streams, or inject derived side effects before the downstream stages run.states()– returns anAsyncStream<State>backed by the relay so you canfor awaitupdates from any context.
Under the hood the default implementation coordinates three pieces:
- Action channel – buffers actions until the pipeline is ready and then
yields them to
mutate(action:). - Mutation channel – fans out every mutation stream so multiple async producers can run at once. This guarantees that quick, synchronous mutations are not blocked behind slower async ones.
- CurrentValueRelay – a small actor contained in this package that stores
the latest state and replays it to new subscribers, much like
CurrentValueSubjectbut designed explicitly for async/await.
This architecture matches the spirit of ReactorKit/Combiner while keeping the surface area focused and dependency-free.
You can throttle actions by overriding transform(action:) and reshaping the
incoming stream before it reaches mutate(action:). The example below shows how
to use swift-async-algorithms
to slow down search requests to once every 300 ms.
Add the dependency to your Package.swift:
.package(url: "https://github.com/apple/swift-async-algorithms", from: "1.0.0"),
.target(
name: "YourFeature",
dependencies: [
.product(name: "AsyncAlgorithms", package: "swift-async-algorithms"),
.product(name: "AsyncViewModel", package: "AsyncViewModel")
]
)Then throttle the action stream:
import AsyncAlgorithms
import AsyncViewModel
import Observation
@Observable
final class SearchViewModel: AsyncViewModel {
struct State: Sendable {
var query: String = ""
var results: [Result] = []
}
enum Action: Sendable {
case queryChanged(String)
case resultsLoaded([Result])
}
let initialState = State()
func transform(action: ActionStream) -> ActionStream {
action.throttle(for: .milliseconds(300), clock: .continuous)
}
// mutate(action:) would start the search request and emit resultsLoaded,
// reduce(state:mutation:) would update the array.
}If your action enum contains additional cases, forward the non-query actions directly to the downstream continuation and only apply AsyncAlgorithms to the text-change stream.
This repository uses the Swift 6 Testing package API. Run the suite with:
swift testThe test suite includes coverage for:
- Core pipeline behaviour (
asyncViewModelLifecycle). - Blocking regressions when mixing asynchronous and synchronous mutations.
- Replay semantics of the built-in
CurrentValueRelay.
Use the same Testing module in your own targets to create lightweight,
async-aware specifications for view models built on this package.
- Combiner – the Combine-based predecessor.
AsyncViewModel is available under the MIT license. See
LICENSE for details.