MVVM-lite with @Observable
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.
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) }
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.