Skip to content

Navigation Menu

Sign in
Appearance settings

Search code, repositories, users, issues, pull requests...

Provide feedback

We read every piece of feedback, and take your input very seriously.

Saved searches

Use saved searches to filter your results more quickly

Appearance settings

Latest commit

 

History

History
History
248 lines (188 loc) · 7.05 KB

File metadata and controls

248 lines (188 loc) · 7.05 KB
Copy raw file
Download raw file
Outline
Edit and raw actions

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.

Morty Proxy This is a proxified and sanitized view of the page, visit original site.