Skip to content
Commitwise
Menu
iOS Development

Architecture: MVVM, TCA and coordinators

Choose an architecture for a SwiftUI app, from plain views with @Observable models to MVVM view models owned with @State, The Composable Architecture with reducers, effects and exhaustive tests, and a router or coordinator for navigation, with the trade-offs of each.

intermediate

The core idea with one simple example.Step by step, with more examples and exercises.A cheat sheet: key APIs, gotchas and trade-offs.Internals, trade-offs and production pitfalls.

The first SwiftUI screens of a recipe app put everything in the view: the network call in .task, the filtering in a computed property, the “save to favorites” logic in a button closure. Previews needed a network connection, nothing could be unit tested, and the favorites logic was copied into three views.

Moving logic out of views is the obvious next step, but SwiftUI gives you several ways to do it and the internet argues about all of them. This tutorial covers the three you’ll meet in real iOS codebases: MVVM with @Observable view models, The Composable Architecture (TCA), and coordinators or routers for navigation, and when each is worth its cost.

New to some of these? Open what you need, or skip ahead.

Basics:What is @Observable?

@Observable (iOS 17 and later) turns a class into one SwiftUI can watch: when a property a view reads changes, that view updates. A view that owns such an object keeps it in @State. The State and data flow tutorial covers it.

Basics:What is a reducer?

A reducer is a function that takes the current state and an action (“user tapped Save”) and produces the new state. Because it doesn’t do anything else, it’s easy to test: given this state and this action, the result must be that state. The Unidirectional data flow tutorial covers it.

Part Not started

MVVM with @Observable

In MVVM, each screen gets a view model: a class that holds what the screen shows (recipes, loading, error) and what it can do (load, toggle favorite). The view only draws the view model’s state and calls its methods.

Two things make it work in SwiftUI:

  • The view model is @Observable, so views update when the state they read changes.
  • The view that creates the view model keeps it in @State. SwiftUI recreates view structs all the time; @State makes sure the view model is created once and kept for as long as the view is on screen.

Dependencies (the API, the favorites store) are passed to the view model’s initializer, so previews and tests can pass fakes.

With the Observation framework, MVVM in SwiftUI is lightweight: an @Observable @MainActor final class with state properties and intent methods, dependencies injected through init, owned by the screen’s root view via @State (lifetime tied to the view’s identity) and passed down as a plain reference. Observation tracks property reads per view, so unrelated changes don’t invalidate views (unlike ObservableObject, where any @Published change invalidated every observer). Whether a screen needs a view model at all is a real question: Apple’s own samples often use views plus shared @Observable models (a FavoritesStore) and keep view-local state in the view. A view model earns its place when a screen has logic worth testing without UI (loading orchestration, validation, combining several sources) or must be previewed in many states.

@Observable @MainActor
final class RecipeListViewModel {
    enum State { case loading, loaded([Recipe]), failed(String) }

    private(set) var state: State = .loading
    var searchText = ""

    private let api: RecipesAPI
    private let favorites: FavoritesStore

    init(api: RecipesAPI, favorites: FavoritesStore) {
        self.api = api
        self.favorites = favorites
    }

    var visibleRecipes: [Recipe] {                       // derived, never stored
        guard case .loaded(let recipes) = state else { return [] }
        return searchText.isEmpty ? recipes : recipes.filter { $0.title.localizedCaseInsensitiveContains(searchText) }
    }

    func isFavorite(_ recipe: Recipe) -> Bool { favorites.contains(recipe.id) }

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

    func toggleFavorite(_ recipe: Recipe) { favorites.toggle(recipe.id) }
}

struct RecipeListScreen: View {
    @State private var viewModel: RecipeListViewModel     // owned here: created once

    init(api: RecipesAPI, favorites: FavoritesStore) {
        _viewModel = State(initialValue: RecipeListViewModel(api: api, favorites: favorites))
    }

    var body: some View {
        List(viewModel.visibleRecipes) { recipe in
            RecipeRow(recipe: recipe, isFavorite: viewModel.isFavorite(recipe)) {
                viewModel.toggleFavorite(recipe)
            }
        }
        .searchable(text: $viewModel.searchText)
        .overlay { if case .loading = viewModel.state { ProgressView() } }
        .task { await viewModel.load() }
    }
}

#Preview {
    RecipeListScreen(api: FakeRecipesAPI(recipes: .samples), favorites: FavoritesStore())
}
  • state is an enum, so the screen is always exactly one of loading, loaded or failed.
  • visibleRecipes is computed from the state and the search text; there’s no second list to keep in sync.
  • @State private var viewModel keeps the same view model while the screen is shown, even when SwiftUI recreates the RecipeListScreen struct.
  • $viewModel.searchText works because @State gives you bindings to the properties of an @Observable object.
  • The preview passes a fake API with sample recipes, so it works offline and instantly.
Spot the bugThe search text resets while typing

Tap the line with the bug.

struct RecipesTab: View {
    @Environment(AppDependencies.self) private var deps
    @State private var showFilters = false

    var body: some View {
        NavigationStack {
            RecipeListView(viewModel: RecipeListViewModel(api: deps.api, favorites: deps.favorites))
                .toolbar { Button("Filters") { showFilters.toggle() } }
        }
    }
}

struct RecipeListView: View {
    @Bindable var viewModel: RecipeListViewModel
    var body: some View { /* list, .searchable($viewModel.searchText), .task { load } */ }
}

Predict the output

RecipeListViewModel is @Observable. A RecipeRow view only reads recipe.title and isFavorite. The view model's searchText changes as the user types. Does RecipeRow's body run on each keystroke?

  • @Observable @MainActor final class view models, dependencies via init, owned with @State by the creating view, passed down as references (@Bindable for bindings).
  • Observation tracks reads per view: fine-grained invalidation, unlike ObservableObject.
  • Never construct a view model in a parent’s body: it’s recreated on every parent update, losing state and repeating work.
  • View models are optional: shared @Observable stores plus view-local state can be enough; add a view model when logic deserves tests or previews need many states.

Check this part

  1. Why does the view that creates a view model keep it in @State?

  2. Why pass the API into the view model's initializer?

  3. What happens when a parent's body creates RecipeListViewModel(...) inline?

A view model is an @Observable class holding a screen's state and logic, with dependencies passed in. The view that owns it keeps it in @State so it survives view updates; views that receive it just hold a reference. Small screens can skip view models and use shared @Observable models directly.

Part Not started

The Composable Architecture and navigation

The Composable Architecture (TCA) is a popular open-source library from Point-Free. Every feature is written the same way:

  • State: a struct with everything the feature shows.
  • Action: an enum of everything that can happen: user taps, responses from the server.
  • Reducer: a function that changes the state for each action and returns effects (work like network calls) that send more actions when they finish.
  • Store: runs it all and gives the view the current state.

The big advantage is testing: a TestStore lets you send an action and say exactly how the state must change, and it fails if anything else changed or an effect wasn’t handled. The cost is more code and a library everybody on the team has to learn.

Separately from MVVM or TCA, navigation is often pulled out of screens too: a router (or coordinator) owns the NavigationStack’s path, and screens just ask it to show things.

TCA’s @Reducer macro generates the feature type; @ObservableState makes state observable by SwiftUI; Effect.run performs async work and sends actions back; @Dependency resolves controllable dependencies (live, preview and test values; unimplemented test values fail loudly when a test forgets to override them); and TestStore asserts exhaustively on every state mutation and received action. Composition is first-class: child features scoped into parents with Scope, ifLet, forEach, and navigation as state (StackState, @Presents). Costs: compile times on large reducers, boilerplate, and a framework dependency that shapes the whole codebase and evolves with breaking changes. It pays off in large apps with complex, shared, long-lived state and teams who value exhaustive tests; for small apps, MVVM with protocol-based dependencies is usually enough. Navigation either way: a router owning NavigationStack(path:) with a Hashable route enum, which also makes deep links and state restoration a matter of building that array (see Coordinator).

import ComposableArchitecture

@Reducer
struct RecipeDetail {
    @ObservableState
    struct State: Equatable {
        let recipe: Recipe
        var isFavorite = false
        var isSaving = false
    }

    enum Action {
        case favoriteTapped
        case saveResponse(Result<Bool, Error>)
    }

    @Dependency(\.favoritesClient) var favorites

    var body: some ReducerOf<Self> {
        Reduce { state, action in
            switch action {
            case .favoriteTapped:
                state.isFavorite.toggle()                      // optimistic update
                state.isSaving = true
                return .run { [id = state.recipe.id, value = state.isFavorite] send in
                    await send(.saveResponse(Result { try await favorites.set(id, value) }))
                }
            case .saveResponse(.success):
                state.isSaving = false
                return .none
            case .saveResponse(.failure):
                state.isSaving = false
                state.isFavorite.toggle()                      // roll back
                return .none
            }
        }
    }
}

@MainActor
@Test func favoriteRollsBackOnFailure() async {
    let store = TestStore(initialState: RecipeDetail.State(recipe: .pancakes)) {
        RecipeDetail()
    } withDependencies: {
        $0.favoritesClient.set = { _, _ in throw URLError(.notConnectedToInternet) }
    }

    await store.send(.favoriteTapped) {
        $0.isFavorite = true
        $0.isSaving = true
    }
    await store.receive(\.saveResponse.failure) {
        $0.isSaving = false
        $0.isFavorite = false
    }
}

Production puzzles

Predict the output

In the TCA test above, a developer adds state.lastSavedAt = Date() in the .saveResponse(.success) branch, but the failure test doesn't change. A different success test asserts only isSaving = false. What happens to that success test?

Predict the output

An MVVM app uses @Observable view models. A view model's load() is started from .task, and the user leaves the screen before the request finishes. What happens to the request?

Choosing

Views + shared modelsMVVMTCA
BoilerplateLeastModerateMost
TestingModel tests; views via previewsView model unit testsExhaustive reducer tests
Learning curveSwiftUI onlySwiftUI + conventionsLibrary concepts, macros
FitsSmall apps, simple screensMost appsLarge apps with complex shared state

Mixing is common and fine: MVVM for most screens, a shared store for cross-cutting state (cart, session), and a router for navigation.

Exerciseeasy

Does this screen need a view model?

For each screen, decide whether you’d add a view model: (1) an About screen with text and a link; (2) a sign-up form with validation, a username availability check while typing, and a submit button; (3) a settings toggle list backed by UserDefaults.

›

Is there logic you’d want to test without the UI, or several states you’d want to preview?

SolutionTry it yourself first
  1. No. Static content; a view is enough.
  2. Yes. Validation rules, a debounced network check, a submit state (idle, submitting, failed). All of it is worth unit tests, and previews of each state help.
  3. Probably not. @AppStorage in the view (or a small shared settings model) covers it. Add a view model only if toggles trigger logic, like re-registering for push notifications.
  • TCA: @Reducer, @ObservableState, Effect.run, @Dependency with live/preview/test values, TestStore exhaustive assertions (exhaustivity = .off opt-out).
  • Costs: boilerplate, compile times, a framework shaping the codebase; pays off with complex shared state and large teams.
  • Control time and randomness via dependencies, or exhaustive tests can’t assert.
  • .task cancels on disappear: treat CancellationError as non-errors; cancel bare Tasks explicitly.
  • Navigation via a router owning NavigationStack(path:) with a Hashable route enum; mixing styles is fine.

Check this part

  1. In TCA, where do network calls happen?

  2. Why does TestStore fail when the reducer changes a field the test didn't mention?

  3. What should a view model do with CancellationError from a cancelled .task?

TCA models a feature as State, Action and a Reducer whose side effects are returned as Effects, with dependencies controlled by the library and exhaustive TestStore tests. It costs boilerplate and a learning curve. Navigation is pulled out of screens into a router or coordinator that owns the NavigationStack path.

Key takeaways

  • A view model holds a screen’s state and logic; the view only draws it.
  • The view that creates a view model keeps it in @State.
  • Pass dependencies in, so previews and tests use fakes.
  • TCA adds a strict structure with excellent tests, at the cost of more code.
  • @Observable view models: injected dependencies, explicit ownership, fine-grained updates.
  • TCA: reducers, effects, controlled dependencies and exhaustive tests.
  • Routers or coordinators own navigation state.
  • Pick per app size and team; mixing approaches is normal.

Finished this page?Nice work, this page is complete!

Mark it as done to track your progress. It's saved in this browser, no account needed.

Its cards are now in your Bites review ⚡
Esc

Type to search the whole site.