modern-app-patterns

MVVM with ObservableObject (SwiftUI)

Expose view state via @Published; update via async actions.

Example

// TodoViewModel.swift
import Foundation

@MainActor
final class TodoViewModel: ObservableObject {
    enum State {
        case idle
        case loading
        case success([String])
        case error(String)
    }

    @Published private(set) var state: State = .idle
    private let repo: TodosRepo

    init(repo: TodosRepo) { self.repo = repo }

    func load() async {
        state = .loading
        do { let items = try await repo.list(); state = .success(items) }
        catch { state = .error(error.localizedDescription) }
    }
}
// TodoView.swift
import SwiftUI

struct TodoView: View {
    @StateObject var vm: TodoViewModel
    var body: some View {
        content
            .task { if case .idle = vm.state { await vm.load() } }
    }
    @ViewBuilder private var content: some View {
        switch vm.state {
        case .idle: Button("Load") { Task { await vm.load() } }
        case .loading: ProgressView()
        case .error(let msg): VStack { Text(msg); Button("Retry") { Task { await vm.load() } } }
        case .success(let items): List(items, id: \.self, rowContent: Text.init)
        }
    }
}

Notes


Live end-to-end example (copy/paste)

Minimal repo + VM + SwiftUI view.

// TodosRepo.swift (Domain)
protocol TodosRepo { func list() async throws -> [String] }
struct FakeTodosRepo: TodosRepo { func list() async throws -> [String] { ["Milk","Bread","Eggs"] } }
// TodoViewModel.swift (VM)
@MainActor
final class TodoViewModel: ObservableObject {
    enum State { case loading, data([String]), error(String) }
    @Published private(set) var state: State = .loading
    private let repo: TodosRepo
    init(repo: TodosRepo) { self.repo = repo }
    func load() async {
        state = .loading
        do { let items = try await repo.list(); state = .data(items) }
        catch { state = .error(error.localizedDescription) }
    }
}
// TodoView.swift (UI)
struct TodoView: View {
    @StateObject var vm: TodoViewModel
    var body: some View {
        content.task { await vm.load() }
    }
    @ViewBuilder private var content: some View {
        switch vm.state {
        case .loading: ProgressView()
        case .error(let msg): VStack{ Text(msg); Button("Retry"){ Task { await vm.load() } } }
        case .data(let items): List(items, id: \.self, rowContent: Text.init)
        }
    }
}

Notes

Sandbox copy map

Paste into an Xcode SwiftUI app (see sandboxes/ios-swiftui):