MVVM-lite with @Observableswift-6.4/ios-26
Lesson 1 / 4
Unit 13 · Architecture & craft

MVVM-lite with @Observable

Coming from
Not covered in this lesson — showing TypeScript.
Note

Unit 13 · Architecture & craft. This is the default architecture Segue teaches. It pulls together @Observable (Unit 8), async/await and @MainActor (Unit 2), and enums (Unit 1).

MVVM-lite is the pattern Segue reaches for by default on any screen with real behavior: an @Observable view model owns the state and the logic, the view binds to it and does nothing else, and the screen's lifecycle is an explicit enum rather than a scatter of booleans. "Lite" because there's no framework, no base class, no bindings library — just a class, an enum, and a view.

Model the state as one enum

The most common view-model bug is representing a screen's status with separate flags — isLoading, error, items — which can drift into impossible combinations (loading and errored, loaded with an error still set). Make the states mutually exclusive with an enum:

enum LoadState<Value> {
    case idle
    case loading
    case loaded(Value)
    case failed(String)
}

One property of this type can only ever be in one state. The view has exactly four cases to render, and no combination of flags can contradict itself.

The view model

The model is an @Observable class, marked @MainActor because it drives UI and its state must be mutated on the main thread. It owns its dependencies (injected), its state, and the async method that transitions between states:

import Observation

@Observable
@MainActor
final class ArticleListModel {
    private let api: ArticleService
    private(set) var state: LoadState<[Article]> = .idle

    init(api: ArticleService) {
        self.api = api
    }

    func load() async {
        state = .loading
        do {
            let articles = try await api.fetchArticles()
            state = .loaded(articles)
        } catch {
            state = .failed("Couldn't load articles.")
        }
    }
}

Note what's here: the dependency arrives through init (testable — Unit 8), state is private(set) so only the model can transition it, and load() is the only place the async lifecycle lives. There is no business logic anywhere else to keep in sync.

Tip

Why @MainActor on the whole class. The model's state feeds SwiftUI, which requires main- thread mutation. Annotating the class puts every property and method on the main actor, so you can't accidentally mutate state from a background context — the compiler enforces it. The await api.fetchArticles() still runs its network work off the main thread; only the suspension points hop back to the main actor to assign state.

The view: a thin projection

The view holds the model in @State, kicks off loading with .task, and switches over the state. That's all it does — no logic, no networking, no error formatting:

struct ArticleListView: View {
    @State private var model: ArticleListModel

    init(api: ArticleService) {
        _model = State(initialValue: ArticleListModel(api: api))
    }

    var body: some View {
        Group {
            switch model.state {
            case .idle, .loading:
                ProgressView()
            case .loaded(let articles):
                List(articles) { Text($0.title) }
            case .failed(let message):
                ContentUnavailableView(message, systemImage: "exclamationmark.triangle")
            }
        }
        .task { await model.load() }
    }
}

The view is now a pure function of model.state: give it a state, you know exactly what it renders. Everything that could go wrong lives in one testable method on the model.

Why this is testable

Because the logic sits in a plain class with an injected dependency, a test never touches SwiftUI. It constructs the model with a stub service, drives load(), and asserts on state:

@Test @MainActor
func loadTransitionsToLoaded() async {
    let model = ArticleListModel(api: StubService(articles: [.sample]))
    await model.load()
    guard case .loaded(let items) = model.state else {
        Issue.record("expected .loaded, got \(model.state)")
        return
    }
    #expect(items.count == 1)
}
Coming from TypeScript

This is the same separation as a React component reading from a hook or store: the component renders state, the store holds logic and effects. The difference is that @Observable does the dependency tracking for you (no selectors, no useMemo), and Swift's enum with associated values gives you a discriminated union for the load state that TypeScript would model with a tagged union — same idea, enforced by the compiler on both ends.

Knowledge check

Q: Why model load status as one enum instead of isLoading/error/items flags? Separate flags allow impossible combinations (loading and errored at once). An enum makes the states mutually exclusive, so the view has exactly one case to render and no contradictory state can exist.

Q: Why is the view model marked @MainActor? Its state drives SwiftUI, which requires main-thread updates. @MainActor on the class makes every mutation main-thread-isolated at compile time, while awaited work inside its methods still runs off the main thread.