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.
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".
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.
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:
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.
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.
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.
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
idvalue. Whenidchanges, 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:
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?
Navigation is a value
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.
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
}
}
}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 topath. 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?
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.
You can now:
- Replace a set of loose boolean flags with a
LoadStatemachine, 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
@Stateownership gives you and what.task(id:)gives you - Drive
NavigationStackfrom 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.