What the C adds
MVVM splits a screen into three parts. The view draws state and forwards taps. The view model holds that state and the logic behind it. The model or service layer fetches and stores data. It works well until a tap has to open another screen.
The usual shortcut is a NavigationLink inside the view, or a view model that knows about the next screen. Both tie screens together: a list now knows how to build its detail view, and a unit test for the list has to cope with navigation it does not care about.
MVVM-C, which is MVVM with Coordinators, removes that coupling with one rule: nobody except the coordinator knows what comes next.
- View: renders state and sends intents such as taps. It never creates another screen.
- View model: owns state and logic. It reports what happened as events, without saying where to go.
- Coordinator: listens to events, owns the navigation path and builds each screen with its dependencies.
Routes and the coordinator
Start by naming every destination as a value. A Hashable enum works with NavigationStack, survives state restoration and doubles as a deep-link format.
enum Route: Hashable {
case detail(id: Int)
case settings
}
final class AppCoordinator: ObservableObject {
@Published var path: [Route] = []
func show(_ route: Route) { path.append(route) }
func pop() { _ = path.popLast() }
func popToRoot() { path.removeAll() }
}
The coordinator is an ObservableObject because NavigationStack reads and writes its path through a binding. Anything that changes path, whether a tap, a deep link or a test, changes what is on screen.
struct RootView: View {
@ObservedObject var coordinator: AppCoordinator
var body: some View {
NavigationStack(path: $coordinator.path) {
ListView(viewModel: coordinator.listViewModel)
.navigationDestination(for: Route.self) { route in
coordinator.destination(for: route)
}
}
}
}
View models emit events
The list view model needs no idea that a detail screen exists. It publishes what happened and nothing else.
import Combine
struct Item: Identifiable, Equatable {
let id: Int
let title: String
}
enum ListEvent: Equatable {
case didSelect(id: Int)
case didTapSettings
}
final class ListViewModel: ObservableObject {
@Published private(set) var items: [Item]
let events = PassthroughSubject<ListEvent, Never>()
init(items: [Item]) { self.items = items }
func select(_ item: Item) { events.send(.didSelect(id: item.id)) }
func settingsTapped() { events.send(.didTapSettings) }
}
@Published carries state to the view. A PassthroughSubject carries one-off events to the coordinator. Events are not state: a navigation request should fire once and be forgotten, not sit in a property that the next screen reads again.
Wiring the two with Combine
The coordinator creates the view model, subscribes to its events and translates each one into a route.
import Combine
final class AppCoordinator: ObservableObject {
@Published var path: [Route] = []
// Load the items from your repository in a real app.
let listViewModel = ListViewModel(items: [])
private var cancellables = Set<AnyCancellable>()
init() {
listViewModel.events
.sink { [weak self] event in self?.handle(event) }
.store(in: &cancellables)
}
private func handle(_ event: ListEvent) {
switch event {
case .didSelect(let id): show(.detail(id: id))
case .didTapSettings: show(.settings)
}
}
func show(_ route: Route) { path.append(route) }
}
Detail screens are built when the route is shown. The coordinator hands each one its dependencies, and the view creates its view model through StateObject.
extension AppCoordinator {
@ViewBuilder
func destination(for route: Route) -> some View {
switch route {
case .detail(let id):
DetailView(viewModel: DetailViewModel(id: id))
case .settings:
SettingsView()
}
}
}
final class DetailViewModel: ObservableObject {
let id: Int
init(id: Int) { self.id = id }
}
struct DetailView: View {
@StateObject private var viewModel: DetailViewModel
init(viewModel: @autoclosure @escaping () -> DetailViewModel) {
_viewModel = StateObject(wrappedValue: viewModel())
}
var body: some View {
Text("Item \(viewModel.id)")
}
}
SwiftUI can call destination(for:) many times. Passing the view model through an autoclosure into StateObject means it is built once for the life of the screen, not on every refresh.
Testing without a UI
Both halves test with plain XCTest and no view hierarchy. The view model test checks the event. The coordinator test checks the route.
import XCTest
import Combine
final class NavigationTests: XCTestCase {
func test_selectingAnItemEmitsDidSelect() {
let item = Item(id: 7, title: "Seven")
let sut = ListViewModel(items: [item])
var received: [ListEvent] = []
let cancellable = sut.events.sink { received.append($0) }
sut.select(item)
XCTAssertEqual(received, [.didSelect(id: 7)])
cancellable.cancel()
}
func test_didSelectPushesTheDetailRoute() {
let sut = AppCoordinator()
sut.listViewModel.select(Item(id: 7, title: "Seven"))
XCTAssertEqual(sut.path, [.detail(id: 7)])
}
}
Deep links get the same treatment: parse a URL into [Route], assign it to path and assert on the result.
Mistakes to avoid
- Building view models in
body. A view model created insidebodyis recreated on every refresh and loses its state. Create it once, in the coordinator or inside theStateObjectautoclosure. - Strong references in sink closures. The coordinator owns the subscription, and the closure points back at the coordinator. Capture
[weak self]. - One giant route enum. When a flow such as sign-up or checkout has its own steps, give it a child coordinator with its own path and let it report back when it finishes.
- Treating events as state. If a screen only works when a
@Publishedflag flips, you have rebuilt the problem MVVM-C removes. Use a subject for one-off events.
In short
Give each role one job: views render, view models decide, the coordinator navigates. You get screens that preview and test in isolation, navigation you can drive from a deep link, and flows you can rearrange without touching a single view.
Next: carrying this architecture over to Dart in From iOS to Flutter: what carries over and what does not.