App architecture · advanced

MVVM in production: state, lifetime and navigation

The screen-level pattern done properly on a real app — screen state as one enum, view models with a clear owner, a clear lifetime and protocol seams for testing, and navigation modelled as a value you can assert on.

14 min read16 min practice0/3 exercises5 recall cards

By the end you will be able to

  • Model a screen's lifecycle as a single enum and derive every face from it
  • Give a view model an owner, a lifetime, and injected protocols that make it testable
  • Drive NavigationStack from a Route array and turn deep links into plain parsing
  • Decide when a screen has earned a view model at all

D1-03 gave you MVVM in one diagram. This lesson gives you the version that holds up in a shipped app.

A tutorial view model holds a results array and little else. A real screen has to handle six things: loading, failure, an empty result, retry, search, and deep links. It has to handle all six without ever landing in a state that makes no sense — a spinner and an error banner on screen at the same time, for example. That gap is where most MVVM codebases go wrong, and this lesson closes it.

We will build on one concrete app. Ledger is a money-movement app: sign in, see your accounts, scroll a transaction feed, send money, dispute a charge. This lesson owns one screen of it, the transaction feed. D1-05 then takes the same app down into its domain layer, and D1-06 assembles the whole system. Across the three lessons you see one production app at three levels: the screen, the business rules underneath it, and the modules that hold them both. That is what a senior interview is asking for when it says "architect a screen for me".

Guess firstAnswering before you read makes the explanation stick — even when you get it wrong.

QA files a bug against the feed screen: the loading spinner and an error banner are visible at the same time. The screen's view model has var isLoading: Bool, var errorMessage: String? and var items: [Transaction]. What is the architectural root cause?

The four faces of a screen

Watch the bug happen. This view model stores the screen as three separate facts. The function render() stands in for a SwiftUI body: it picks one face by checking those three facts in order.

Predict, then runA wrong prediction you have committed to is worth more than a right answer you read.

The feed failed to refresh, so errorMessage was set. The user taps Retry, which starts a new load. The retry path forgets one line. What does the screen show while the retry is still running?

var isLoading = false
var errorMessage: String? = nil
var items: [String] = []

func render() -> String {
    if let message = errorMessage { return "error banner: \(message)" }
    if isLoading { return "spinner" }
    if items.isEmpty { return "friendly empty state" }
    return "list of \(items.count) transactions"
}

// the refresh fails:
errorMessage = "offline"

// the user taps Retry — but this path forgets errorMessage = nil
isLoading = true

print(render())

So store the face itself. One value records which face the screen is showing, and each case carries the data that face needs. That is what enums with associated values are for, which S2-03 introduced:

Loading runnable Swift…

The retry bug can no longer be written. Look at the retry in that example, the line printed as 4: — it is one assignment, state = .loading. Setting that value is clearing the error, because the error was never a separate fact. It was data attached to the .failed case, so the moment state stops being .failed, the message goes with it.

LoadState — the four facesidlenothing yetloadingin flightloaded[Transaction]empty0 itemsfailedmessage.taskitems0 itemserrorretryrefresh / new queryone stored value — every face, transition and derived flag comes from it
The feed's state machine. The arrows are the only transitions the screen allows.

Read the machine left to right. A screen starts at idle. The .task modifier moves it to loading. A load then ends in exactly one of three states: rows arrived (loaded), there are no rows (empty), or the fetch failed (failed). Two arrows lead back into loading. From failed, the user taps retry. From loaded, the user pulls to refresh or types a new search query. There are no other transitions, and that is the point of the design.

Two notes on those faces, both learned in production.

Empty is not a quieter version of loaded. In a money app, "you have no transactions" and "we could not load your transactions" are completely different messages. The first is a true fact about a new account. The second is a failure, and it has to offer a retry. Merge the two into one case and a failed fetch shows an empty transaction list: someone who was paid this morning opens the app and sees nothing at all. That produces a support call, and not a calm one.

Derive, do not store. A computed flag such as var isRefreshing: Bool { if case .loading = state ... } is fine. A computed property is recalculated from state on every read, so it cannot drift away from state. A second stored flag can drift, because some path will update one and not the other. This is the rule U2-02 teaches about @Observable models: keep one source of truth and compute everything else from it.

The view model owns the machine

Something has to own that enum, run its transitions, and talk to the outside world. That is the view model's job description. It is not "the class with the @Observable macro on it" — the macro is only how the view finds out that the state changed.

Loading runnable Swift…

Read what just happened. A complete user story — the load fails while the phone is offline, the user retries, the retry succeeds — was checked with no simulator, no rendering and no real network call. It runs in milliseconds.

FeedViewModel never finds out what its repository really was. "Repository" is just the name for the object it asks for transactions, and here that object could be a URLSession client hitting a server, or a fake — a small stand-in type you write yourself for tests. All the view model knows is TransactionReading, and TransactionReading is declared right next to the view model, in the view model's own words. That is Dependency Inversion from D1-02 doing ordinary daily work: the code that needs a service declares the protocol, and the code that provides the service conforms to it.

One screen, fully wiredTransactionFeedViewrenders the state, forwards intentintentobservesTransactionFeedViewModelstateLoadStateintentsload() · search()owns the seamTransactionReadingprotocolLiveTransactionsURLSession + cacheFakeTransactionstests · previewssame screen, swappable backends — that seam is the whole testing story
The feed screen, fully wired. The dashed protocol in the middle is the seam — the planned place where a real backend or a test fake plugs in.

That dashed protocol is a seam: a place chosen in advance where one implementation can be swapped for another without editing the code on either side. Everything above the seam in that diagram belongs to this lesson. Everything below it belongs to D1-05.

Who creates the view model, and how long does it live? SwiftUI has a precise answer. The view owns it in a @State property, and the object lives exactly as long as the view's identity lives. Identity is the idea from U3-01: an id must name the thing itself, the way a passport number names a person, rather than name a position, the way a seat number does.

struct TransactionFeedView: View {
    @State private var viewModel: FeedViewModel

    init(repository: any TransactionReading) {
        _viewModel = State(initialValue: FeedViewModel(repository: repository))
    }

    var body: some View {
        List(viewModel.rows) { TransactionRow($0) }
            .searchable(text: $viewModel.query)
            .task(id: viewModel.query) {      // re-runs when the query changes…
                await viewModel.load()        // …and cancels the superseded load
            }
            .refreshable { await viewModel.load() }
    }
}

@State gives you two guarantees here. First, the object is built once and survives every re-render. SwiftUI may run body hundreds of times, and FeedViewModel is created on the first run only. Second, the object is thrown away when the view's identity changes. Move from transaction 42's detail screen to transaction 43's and you get a fresh view model holding fresh state. That is what you want. Nothing about transaction 42's load belongs on transaction 43's screen.

Dependencies still arrive through init. A preview or a test builds the view with a FakeTransactions and never touches the network.

.task(id:) ties the async work to two things at once:

  • The view's lifetime. The task starts when the view appears and is cancelled when the view goes away. No work keeps running in the background trying to update a screen the user has already left.
  • The id value. When id changes, SwiftUI cancels the running task and starts a new one. The user types another letter into the search field, the search for the old query is cancelled, and a search for the new query begins. A slow, stale result can no longer overwrite a fresh one.

One correction to a phrase you will hear: this is cancel-and-restart, not a debounce. .task(id:) restarts immediately on every change to id. If you want to wait for typing to settle first, put a short Task.sleep at the top of the task, before the fetch. A task that gets cancelled during that sleep never reaches the fetch at all.

And here is what the view model buys the view. body becomes a single switch over the four faces, with no other conditionals in it:

Loading runnable Swift…
Check yourself

Ledger's Feed screen and its Insights screen (a spending chart) must both update the instant a new transaction lands. Each screen has its own view model. What's the production-grade wiring?

UIKit treated navigation as a side effect. A router or coordinator object held a navigation controller and called pushViewController whenever it wanted a new screen on top. SwiftUI turns that around, the same way it turned rendering around: the navigation stack is a value you set, and the framework then makes the screens match that value. So model where the user can go as data.

Loading runnable Swift…

Connect that array to a real NavigationStack and the screens follow it. The block below cannot run in the browser, because it needs live navigation, but it is the production shape:

struct FeedRoot: View {
    @State private var path: [Route] = []

    var body: some View {
        NavigationStack(path: $path) {
            TransactionFeedView(repository: live)
                .navigationDestination(for: Route.self) { route in
                    switch route {
                    case .detail(let id):  TransactionDetailView(id: id)
                    case .dispute(let id): DisputeFlowView(id: id)
                    }
                }
        }
        .onOpenURL { url in
            path = routes(for: url.absoluteString)   // deep link = parse, assign
        }
    }
}
Navigation is a valueledger://tx/42push · linkparsepath: [Route][.detail(42)]drivesNavigationStackFeedDetail 42top = visibleappend → push · removeLast → back · assert the array → tested navigation
A deep link becomes an array, and the array becomes a screen.

Two things follow from treating navigation as data, and both come up in interviews:

  • Deep links stop being a special case. A push notification saying "your dispute was updated" carries the link ledger://transaction/42/dispute. Handling it is two steps: parse that string into a [Route], then assign the result to path. There is no navigation code to write, because the array is the navigation. Exercise 2 below has you write that parser.
  • Navigation becomes something a test can assert. "After a session times out, the user is back at the root screen" becomes XCTAssertEqual(path, []) — one equality check on a plain array. No UI test driving a simulator and tapping the back button four times.

One boundary worth stating. In a larger app the path usually moves out of the view's @State and into an @Observable object owned at app scope, often called a router. The idea does not change — navigation is still a value — only the owner does. The reason for moving it is that sign-out, session timeouts and push notifications all need to change navigation, and none of them can reach into a view's private @State. D1-06 shows this shape on Ledger. Session state — signed out, locked, active — lives in an app-level observable, and the root view switches on it. Signing out is then one assignment, and it collapses navigation everywhere at once.

Do you actually need a view model?

Loading exercise…
Loading exercise…
Loading exercise…
Teach itSaved on this device. Never graded.

A teammate reviews your LoadState refactor and asks: "It looks tidy, but the old booleans worked. Which kind of bug did we actually delete, and what did it cost us?" Answer in five sentences. Name the kind of bug using the spinner-plus-error example, explain why care and discipline could not prevent it while the enum can, and admit honestly what the enum made more verbose.

Checkpoint

You can now:

  • Replace a set of loose boolean flags with a LoadState machine, and derive every face of the screen from it
  • Write a view model that runs that machine and reaches the outside world through a protocol seam — then test a full user story with a scripted fake
  • Explain in one sentence each what @State ownership gives you and what .task(id:) gives you
  • Drive NavigationStack from a [Route] value, parse deep links as untrusted input, and assert navigation in a test
  • Say when a screen has not earned a view model, and why

Next up: one layer down. D1-05 builds Ledger's send-money domain the Clean Architecture way: entities that refuse invalid values, use cases, mapping the server's JSON into domain types, and error handling at each boundary.

How well do you know this now? Rating yourself honestly, then being tested on it, is how you find out where your intuition is wrong.