Skip to content

9.12. SwiftUI Integration

Wire a ComponentVMOf<Model> to a SwiftUI view with a small Combine adapter that bridges VMx change notifications into SwiftUI's ObservableObject machinery. The adapter decides who owns the VM's lifetime; appearing and disappearing never end it.

9.12.1. Reactivity primitive

SwiftUI re-renders when an ObservableObject publishes through its objectWillChange publisher. VMx VMs publish PropertyChangedMessage to their hub and emit a string-keyed propertyChanged Combine publisher. The adapter forwards every relevant event into objectWillChange.send().

9.12.2. Mapping

SwiftUI VMx
@StateObject adapter ObservableObject wrapper around a ComponentVMOf<M>
objectWillChange.send() propertyChanged subscription
Button(action: …) the domain command's execute(), for example a save command
.onAppear { … } construct an owned VM once; repeated appearances are no-ops
.onDisappear { … } nothing terminal: the view may appear again
adapter released by SwiftUI dispose an owned VM exactly once; never dispose a borrowed VM

Disposal is terminal: a disposed VM cannot be constructed again, so tying dispose() to .onDisappear breaks any view that reappears (tabs, navigation stacks, lazy lists). Choose an ownership policy instead:

  • Owned: the view created the VM. The adapter constructs it on first appearance and disposes it once when SwiftUI releases the @StateObject.
  • Borrowed: a parent VM or app state owns the VM, as the Notes Workspace keeps its WorkspaceVM for the window's lifetime. The view only observes it.

9.12.3. Adapter and view

This is the exact code compiled and exercised by langs/swift/Tests/VMxTests/SwiftUIRecipeTests.swift: appear, disappear, and appear again; owned versus borrowed cleanup; Save; and a surfaced construction failure.

import Combine
import SwiftUI
import VMx

struct Note: Equatable {
    var title: String
}

/// Who ends the view model's lifetime.
enum ViewModelOwnership {
    /// The view created the VM: construct it on first appearance and dispose
    /// it exactly once, when SwiftUI releases the adapter.
    case owned
    /// A parent owns the VM: the view observes it and never changes its
    /// lifecycle.
    case borrowed
}

/// Bridges a VMx view model into SwiftUI. Appearance never ends the VM's
/// lifetime, because a view that disappears may appear again.
final class ViewModelAdapter<Model>: ObservableObject {
    let vm: ComponentVMOf<Model>
    let ownership: ViewModelOwnership
    /// A lifecycle failure for the host to show; never swallowed.
    @Published private(set) var lifecycleError: (any Error)?
    private var cancellables: Set<AnyCancellable> = []

    init(_ vm: ComponentVMOf<Model>, ownership: ViewModelOwnership) {
        self.vm = vm
        self.ownership = ownership
        vm.propertyChanged
            .receive(on: RunLoop.main)
            .sink { [weak self] _ in self?.objectWillChange.send() }
            .store(in: &cancellables)
    }

    /// Call from `.onAppear`. Constructing an already constructed VM is a
    /// no-op, so repeated appearances are safe.
    func appeared() {
        guard ownership == .owned, lifecycleError == nil else { return }
        do {
            try vm.construct()
        } catch {
            lifecycleError = error
        }
    }

    deinit {
        cancellables.removeAll()
        if ownership == .owned {
            vm.dispose()
        }
    }
}

struct NoteView: View {
    @StateObject private var adapter: ViewModelAdapter<Note>
    private let save: any Command

    /// `save` is the domain's save command, e.g. from a form or workspace VM.
    init(vm: ComponentVMOf<Note>, ownership: ViewModelOwnership, save: any Command) {
        _adapter = StateObject(wrappedValue: ViewModelAdapter(vm, ownership: ownership))
        self.save = save
    }

    var body: some View {
        VStack {
            if let error = adapter.lifecycleError {
                Text("Could not open this note: \(String(describing: error))")
            }
            Text(adapter.vm.model.title)
            Button("Save") { save.execute() }
                .disabled(!save.canExecute())
        }
        .onAppear { adapter.appeared() }
    }
}

The Save button runs the command it is given, typically the save or approve command of a form or workspace VM. A component's selectCommand only selects it in its parent; it persists nothing.

Construction errors are not discarded with try?: the adapter stores them in lifecycleError so the host can show them, and later cleanup cannot mask them.

9.12.4. Lifecycle is throwing (ADR-0053)

As of the v3 convergence (ADR-0053, superseding ADR-0037 §2.5), the Swift lifecycle operations construct(), destruct(), and reconstruct() are throws — matching the catchable exceptions the C#/Python/TypeScript flavors already raise, instead of the earlier uncatchable preconditionFailure trap. An illegal transition (e.g. construct() on a disposed VM) or a concurrent re-invocation while a transition is in flight throws a catchable StatusTransitionError; the legal idempotent no-ops (construct from Constructed, destruct from Destructed) still return without throwing.

do {
    try vm.construct()
} catch let error as StatusTransitionError {
    // Recover — the VM is left in its prior settled state, not crashed.
    print("illegal lifecycle transition: \(error)")
}

A non-child current assignment likewise has a throwing companion (setCurrent(_:) throws, throwing CompositeMembershipError); see ADR-0053 §2.2.

9.12.5. Fuller example

The SwiftUI Notes Workspace flagship lives at examples/swift/notes-showcase/. Its NotesShowcaseCore target keeps the pure VM layer separate from SwiftUI, while the app target contains the Combine-to-SwiftUI binding bridge.

9.12.6. Cross-flavor parity

This recipe parallels the React adapter (react.md) — both bridge a hub message stream into the framework's "re-render this view" hook. The Avalonia (avalonia.md) and Textual (textual.md) recipes follow the same shape.