NavigationStack Basics
NavigationStack, introduced to replace the older NavigationView, is SwiftUI's container for presenting a stack of screens where each new screen is pushed on top of the previous one, mirroring the classic push/pop model familiar from UINavigationController. Unlike NavigationView's implicit, link-based navigation, NavigationStack separates 'what triggers navigation' from 'what data type maps to what destination view,' using navigationDestination(for:destination:) modifiers. This separation is what makes deep linking, state restoration, and programmatic navigation tractable in a way NavigationView never was.
Cricket analogy: Old-school scoring separated nothing — the scorer who noted a boundary was also the one deciding how it displayed; NavigationStack instead separates "a four was hit" from "how the graphic renders it," using a lookup table the same way navigationDestination(for:) maps data types to destination views.
Declaring Destinations by Data Type
Instead of wrapping each pushable row in a NavigationLink that directly embeds its destination view, NavigationStack lets you register a mapping from a data type to a destination view once, near the top of the stack. Any NavigationLink whose value matches that type will trigger that destination — this keeps row views lightweight (they just carry a value) and keeps the navigation logic centralized and easy to reason about.
Cricket analogy: A scorecard app doesn't need every row of the batting lineup to know how to render a player's full profile page — each row just carries the player's ID, and one central lookup near the top decides how to display any player, keeping row views lightweight.
struct Recipe: Identifiable, Hashable {
let id: UUID
let name: String
}
struct RecipeListView: View {
let recipes: [Recipe]
var body: some View {
NavigationStack {
List(recipes) { recipe in
NavigationLink(recipe.name, value: recipe)
}
.navigationTitle("Recipes")
.navigationDestination(for: Recipe.self) { recipe in
RecipeDetailView(recipe: recipe)
}
}
}
}
struct RecipeDetailView: View {
let recipe: Recipe
var body: some View {
Text(recipe.name)
.navigationTitle(recipe.name)
}
}One NavigationStack Per Independent Flow
Each tab in a TabView, or each independently navigable region of your UI, generally needs its own NavigationStack, because a stack owns its own push/pop history. Nesting a NavigationStack inside another is almost always a mistake and leads to confusing or broken navigation bars; instead, place a single NavigationStack at the root of each flow and push all screens for that flow within it.
Cricket analogy: Each cricket tournament (IPL, Big Bash, The Hundred) keeps its own independent points table and history — you'd never nest one tournament's standings inside another's; likewise each tab needs its own NavigationStack, since nesting one inside another breaks the push/pop history.
Applying multiple navigationDestination(for:) modifiers for the SAME data type at different points in the view hierarchy, or forgetting to declare navigationDestination(for:) at all for a type you push, are frequent sources of 'nothing happens when I tap this row' bugs. The destination modifier must be reachable from where the NavigationLink lives, typically attached near the root of that stack.
Think of NavigationStack as a deck of index cards: the root view is the bottom card, and pushing a new screen puts a new card on top. navigationDestination(for:) is the rulebook that says 'if you draw a card of type Recipe, display it using RecipeDetailView' — the rulebook only needs to be written once for the whole deck.
- NavigationStack replaces NavigationView and models navigation as an explicit stack of pushed screens.
- navigationDestination(for:destination:) maps a Hashable data type to a destination view, decoupling triggers from destinations.
- NavigationLink(value:) pushes a value onto the stack; the matching navigationDestination(for:) renders it.
- Each independent navigable flow (e.g. each TabView tab) should have exactly one NavigationStack at its root.
- Missing or duplicated navigationDestination(for:) declarations are the most common cause of broken pushes.
- This value-driven model is what enables programmatic navigation and deep linking, covered in NavigationPath.
Practice what you learned
1. What is the main structural difference between NavigationStack and the older NavigationView?
2. If tapping a NavigationLink(value:) does nothing, what is the most likely cause?
3. How many NavigationStacks should a TabView with three tabs, each independently navigable, typically use?
4. What type must a value passed to NavigationLink(value:) conform to?
5. In the recipe list example, what does navigationDestination(for: Recipe.self) { recipe in RecipeDetailView(recipe: recipe) } do?
Was this page helpful?
You May Also Like
NavigationPath and Programmatic Navigation
Learn how NavigationPath lets you drive SwiftUI navigation from code — deep linking, popping to root, and building multi-step flows without user taps.
Passing Data Between Views
A practical guide to the different ways data flows through a SwiftUI view hierarchy — initializers, @Binding, environment, and shared observable models.
TabView and Sheets
Compare SwiftUI's two main top-level presentation patterns — persistent tab-based navigation with TabView and modal, dismissible content with sheets.
App and Scene Structure
Understand how the App and Scene protocols define a SwiftUI application's entry point, window management, and lifecycle across platforms.