Skip to content

Swift Concurrency-first MVx helper: buffered actions, async mutation streams, and replayable state for UI and tests.

License

Notifications You must be signed in to change notification settings

velos/AsyncViewModel

Repository files navigation

AsyncViewModel

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
Loading

Highlights

  • 🧠 Protocol-based API – adopt AsyncViewModel and provide your own State, Action, and optional Mutation types.
  • 🔁 Buffered actionssend(_:) 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 you AsyncStream access via states().
  • 🧪 Testable by design – the package ships with tests that exercise the whole flow using Swift's new lightweight Testing module.

Requirements

  • Swift 6.0 (tools version 6.2)
  • Platforms: macOS 13, iOS 17, tvOS 15, watchOS 10

Installation

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")
    ]
)

Quick Start

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)

Documentation

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 Testing package.

Key APIs

  • 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 an AsyncStream<State> backed by the relay so you can for await updates from any context.

How It Works

Under the hood the default implementation coordinates three pieces:

  1. Action channel – buffers actions until the pipeline is ready and then yields them to mutate(action:).
  2. 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.
  3. CurrentValueRelay – a small actor contained in this package that stores the latest state and replays it to new subscribers, much like CurrentValueSubject but designed explicitly for async/await.

This architecture matches the spirit of ReactorKit/Combiner while keeping the surface area focused and dependency-free.

Pattern: Throttled Search Input

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.

Testing

This repository uses the Swift 6 Testing package API. Run the suite with:

swift test

The 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.

Related Projects

  • Combiner – the Combine-based predecessor.

License

AsyncViewModel is available under the MIT license. See LICENSE for details.

About

Swift Concurrency-first MVx helper: buffered actions, async mutation streams, and replayable state for UI and tests.

Resources

License

Stars

Watchers

Forks

Releases

No releases published

Packages

No packages published

Languages