SwiftUI provides several tools for managing navigation, and the introduction of NavigationStack and value-destination links improved programmatic navigation.
However, in larger applications, vanilla SwiftUI navigation can pose challenges for testability, maintainability, and modularity. Navigation logic is distributed across views, introducing coupling and making the navigation code hard to locate.
These problems can be addressed by integrating coordinators into the MVVM pattern.

FREE GUIDE - SwiftUI App Architecture: Design Patterns and Best Practices
MVC, MVVM, and MV in SwiftUI, plus practical lessons on code encapsulation and data flow.
Table of contents
- Vanilla SwiftUI navigation is intuitive in small apps, but causes architectural issues at scale
- Coordinators help remove untestable logic from SwiftUI views
- Coordinators simplify the injection of complex dependencies into view models
- Coordinators centralize scattered navigation logic and remove coupling between views
- Coordinators centralize the app’s navigation management for deep links
- Coordinators and route values enable unit testing of a SwiftUI app’s navigation
Vanilla SwiftUI navigation is intuitive in small apps, but causes architectural issues at scale
SwiftUI’s NavigationStack allows you to build sophisticated navigation hierarchies. For example, this is a typical arrangement for an app featuring a tab view, with individual drill-down navigation in each tab.
struct ContentView: View {
var body: some View {
TabView {
Tab("Recipes", systemImage: "list.bullet.clipboard") {
NavigationStack {
RecipesList()
}
}
Tab("Settings", systemImage: "gear") {
NavigationStack {
SettingsView()
}
}
}
}
}
The NavigationLink view allows us to specify:
- Value-destination links that push values onto a navigation path.
- View-destination links that push views directly onto the navigation stack.
While these are both useful, depending on the use case, they both cause architectural issues at scale.
You can follow the code in this article by downloading the complete Xcode project from GitHub.
Note
I will deliberately keep the examples simple to make them straightforward to understand.
It is crucial to understand that none of the following examples, by itself, constitutes a problem. All examples will show perfectly acceptable code for simple apps.
Problems arise only when a codebase grows and its architectural requirements change.
Coordinators help remove untestable logic from SwiftUI views
Value-destination links operate on types. For example, we can display a RecipeView when a Recipe value is pushed onto the navigation path.
struct ContentView: View {
var body: some View {
TabView {
Tab("Recipes", systemImage: "list.bullet.clipboard") {
NavigationStack {
RecipesList()
.navigationDestination(for: Recipe.self) { recipe in
RecipeView(recipe: recipe)
}
}
}
// ...
}
}
}
This approach remains purely declarative until we need to inspect data to make a navigation decision. For example, our recipes app might offer premium recipes gated behind a paywall.
struct ContentView: View {
var body: some View {
TabView {
Tab("Recipes", systemImage: "list.bullet.clipboard") {
NavigationStack {
RecipesList()
.navigationDestination(for: Recipe.self) { recipe in
if recipe.isPremium {
PaywallView()
} else {
RecipeView(recipe: recipe)
}
}
}
}
// ...
}
}
}
Incorporating these checks introduces non-UI logic into views, creating several architectural problems:
- It violates the Single-responsibility principle.
- It introduces coupling among views.
- It makes the app’s business logic untestable.
We can solve the first two problems straight away by moving the navigation logic into a coordinator class.
@Observable final class Coordinator {
@ViewBuilder func destination(for recipe: Recipe) -> some View {
if recipe.isPremium {
PaywallView()
} else {
RecipeView(recipe: recipe)
}
}
}
struct ContentView: View {
@State var coordinator = Coordinator()
var body: some View {
TabView {
Tab("Recipes", systemImage: "list.bullet.clipboard") {
NavigationStack {
RecipesList()
.navigationDestination(for: Recipe.self) { recipe in
coordinator.destination(for: recipe)
}
}
}
// ...
}
}
}
However, testing this code is not straightforward, since the @ViewBuilder attribute causes the destination(for:) to return a _ConditionalContent<PaywallView, RecipeView> value that we cannot inspect.
We will see how to fix this by the end of the article.

FREE GUIDE - SwiftUI App Architecture: Design Patterns and Best Practices
MVC, MVVM, and MV in SwiftUI, plus practical lessons on code encapsulation and data flow.
Coordinators simplify the injection of complex dependencies into view models
Another problem arises with views that use a view model that requires dependencies to be injected via its initializer.
Because environment objects are not yet available in a view’s initializer, the view model must be initiated in the task(priority:_:) view modifier and stored in an optional property.
@Observable final class NetworkController {
// ...
}
@Observable final class ViewModel {
let networkController: NetworkController
init(networkController: NetworkController) {
self.networkController = networkController
}
}
struct PaywallView: View {
@State private var viewModel: ViewModel?
@Environment(NetworkController.self) private var networkController
var body: some View {
Text("Hello, World!")
.navigationTitle("Paywall")
.task {
guard viewModel == nil else { return }
viewModel = ViewModel(networkController: networkController)
}
}
}
Many developers take issue with this approach, complaining that optionals lead to several annoying unwrapping steps in the view’s code.
However, instantiating the view model in the parent of PaywallView would introduce additional coupling across views and violate the Single Responsibility and the Don’t Repeat Yourself (DRY) principles, especially when the PaywallView is accessible via multiple navigation paths.
We can avoid these problems and remove the optional from the view by injecting the dependencies into the view model from a coordinator.
struct PaywallView: View {
@State private var viewModel: ViewModel
init(viewModel: ViewModel) {
self._viewModel = State(initialValue: viewModel)
}
var body: some View {
Text("Hello, World!")
.navigationTitle("Paywall")
}
}
@Observable final class Coordinator {
let networkController = NetworkController()
@ViewBuilder func destination(for recipe: Recipe) -> some View {
if recipe.isPremium {
let viewModel = ViewModel(networkController: networkController)
PaywallView(viewModel: viewModel)
} else {
RecipeView(recipe: recipe)
}
}
}
Note
The NetworkController might also need to be injected into the Coordinator through an initializer.
Coordinators centralize scattered navigation logic and remove coupling between views
At times, there might not be a relationship between data and navigation. In such cases, SwiftUI offers view-destination links rather than value-destination links.
For example, a Settings view might explicitly declare the destination view for each row in a Form.
struct SettingsView: View {
var body: some View {
Form {
NavigationLink(destination: { ProfileView() }) {
Label("Profile", systemImage: "person.crop.circle")
}
NavigationLink(destination: { AllergiesView() }) {
Label("Allergies", systemImage: "leaf")
}
}
.navigationTitle("Settings")
}
}
You would be able to gather the mapping of value-destination links inside several navigationDestination(for:_:) view modifiers at the root of a navigation tree inside a NavigationStack.
However, view-destination links distribute navigation responsibilities across multiple views, as they must reside in the view triggering the navigation.
This introduces coupling between views, and, in large apps, it makes it challenging to locate the exact navigation points in the codebase. Identifying specific routes can be time-consuming, and code updates can affect unrelated parts of the system.
Coordinators let us gather all navigation destinations in a single location, making it easier to understand the app’s entire navigation at a glance without drilling into the codebase.
@Observable final class Coordinator {
// ...
@ViewBuilder func profileSettings() -> some View {
ProfileView()
}
@ViewBuilder func allergiesSettings() -> some View {
ProfileView()
}
}
The coordinator also removes coupling between views.
struct ContentView: View {
@State var coordinator = Coordinator()
var body: some View {
TabView(selection: $coordinator.tab) {
// ...
}
.environment(coordinator)
}
}
struct SettingsView: View {
@Environment(Coordinator.self) private var coordinator
var body: some View {
Form {
NavigationLink(destination: { coordinator.profileSettings() }) {
Label("Profile", systemImage: "person.crop.circle")
}
NavigationLink(destination: { coordinator.allergiesSettings() }) {
Label("Allergies", systemImage: "leaf")
}
}
.navigationTitle("Settings")
}
}

FREE GUIDE - SwiftUI App Architecture: Design Patterns and Best Practices
MVC, MVVM, and MV in SwiftUI, plus practical lessons on code encapsulation and data flow.
Coordinators centralize the app’s navigation management for deep links
Another problem caused by value-destination and view-destination links at scale is that they decentralize navigation management, making it hard or impossible to bring an app to a specific state via a deep link.
A coordinator can instead control the entire navigation state of an app, including tab views and modal presentation.
First, it should be noted that view-destination links are not suitable for deep linking. According to Apple’s documentation:
A view-destination link is fire-and-forget: SwiftUI tracks the navigation state, but from your app’s perspective, there are no stateful hooks indicating you pushed a view.
Hence, we need values even for those paths that we would typically handle with view-destination links.
enum AppSection {
case recipes, settings
}
enum SettingsRoute {
case main, profile, allergies
}
@Observable final class Coordinator {
var appSection: AppSection = .recipes
var settingsPath: [SettingsRoute] = []
//...
@ViewBuilder func view(for route: SettingsRoute) -> some View {
switch route {
case .main: SettingsView()
case .profile: ProfileView()
case .allergies: AllergiesView()
}
}
func handleURL(_ url: URL) {
appSection = .settings
settingsPath = [.main, .allergies]
}
}
The view(for:) method maps each SettingsRoute to a view. The handleURL(_:) method can then respond to a deep link, switching the app to the Settings tab and pushing the AllergiesView onto its navigation stack.
The connection is made inside the ContentView, which is the root view where the entire app’s navigation structure is defined.
struct ContentView: View {
@State var coordinator = Coordinator()
var body: some View {
TabView(selection: $coordinator.appSection) {
Tab("Recipes", systemImage: "list.bullet.clipboard", value: .recipes) {
// ...
}
Tab("Settings", systemImage: "gear", value: .settings) {
NavigationStack(path: $coordinator.settingsPath) {
coordinator.view(for: .main)
.navigationDestination(for: SettingsRoute.self) { route in
coordinator.view(for: route)
}
}
}
}
.environment(coordinator)
.onOpenURL { url in
coordinator.handleURL(url)
}
}
}
Deep links often come from outside an app, but they can also be used internally to jump to a specific point in response to user actions or events.
struct RecipesList: View {
@State var recipes = Recipe.data
var body: some View {
List {
ForEach(recipes) { recipe in
// ...
}
Link(
"Set your allergies",
destination: URL(string: "recipes://settings/allergies")!
)
}
.listStyle(.plain)
.navigationTitle("Recipes")
}
}
Do not forget to set your app’s URL scheme in the target’s Info to enable it to respond to incoming deep links.
Coordinators and route values enable unit testing of a SwiftUI app’s navigation
Thanks to our coordinator, we can now write a test to verify that a deep link leads to the correct location.
@Test func allergiesDeepLink() async throws {
let coordinator = Coordinator()
coordinator.handleURL(URL(string: "recipes://settings/allergies")!)
#expect(coordinator.appSection == .settings)
#expect(coordinator.settingsPath == [.main, .allergies])
}
There is still an indirect link between the SettingsRoute values and the relative views that our test cannot cover. However, that is the domain of UI tests, as the goal of a unit test is to verify an app’s logic.
This means that testing the destination for premium recipes also requires explicitly handling the navigation path of the Recipes navigation stack in the coordinator.
Conclusions
Standard SwiftUI navigation is enough for basic applications, but it introduces several architectural problems at scale.
Coordinators within the MVVM pattern centralize routing, removing view coupling, enabling deep linking, and improving separation of concerns and testability.

FREE GUIDE - SwiftUI App Architecture: Design Patterns and Best Practices
MVC, MVVM, and MV in SwiftUI, plus practical lessons on code encapsulation and data flow.
Matteo has been developing apps for iOS since 2008. He has been teaching iOS development best practices to hundreds of students since 2015 and he is the developer of Vulcan, a macOS app to generate SwiftUI code. Before that he was a freelance iOS developer for small and big clients, including TomTom, Squla, Siilo, and Layar. Matteo got a master’s degree in computer science and computational logic at the University of Turin. In his spare time he dances and teaches tango.

