- Published on
> NavigationView vs NavigationStack: What Changed and Why
- Authors

- Name
- Mick MacCallum
- @0x7fs
When iOS 16 introduced NavigationStack, Apple deprecated NavigationView after just three years. This wasn't a minor API tweak. It represented a fundamental rethinking of how navigation should work in SwiftUI. Understanding the differences helps you write better navigation code and makes migration straightforward.
The Problem with NavigationView
NavigationView worked well for simple cases but became awkward as navigation grew complex. The core issue was that navigation state lived implicitly in the view hierarchy rather than explicitly in your data model.
// The old way with NavigationView
struct OldStyleList: View {
let items: [Item]
var body: some View {
NavigationView {
List(items) { item in
NavigationLink(destination: DetailView(item: item)) {
Text(item.name)
}
}
.navigationTitle("Items")
}
}
}
This looks clean, but try answering these questions: What screen is currently showing? How do you programmatically navigate to a specific item? How do you deep link to a detail view three levels deep? The answers required workarounds involving isActive bindings, selection state, and careful coordination.
NavigationStack's Data-Driven Approach
NavigationStack flips the model. Instead of navigation state being scattered across NavigationLink bindings, you maintain a single path that represents the entire navigation stack:
struct ModernList: View {
let items: [Item]
@State private var path = NavigationPath()
var body: some View {
NavigationStack(path: $path) {
List(items) { item in
NavigationLink(value: item) {
Text(item.name)
}
}
.navigationTitle("Items")
.navigationDestination(for: Item.self) { item in
DetailView(item: item)
}
}
}
}
The navigation stack is now just data. path contains the values representing each screen in the stack. You can inspect it, modify it, save it, restore it, or pass it around. Deep linking becomes trivial: just set the path to whatever state you want.
NavigationPath: Type-Erased Flexibility
NavigationPath is a type-erased container that can hold any Hashable values. This lets you push different types onto the same stack:
struct ContentView: View {
@State private var path = NavigationPath()
var body: some View {
NavigationStack(path: $path) {
VStack(spacing: 20) {
Button("Go to User") {
path.append(User(id: 1, name: "Alice"))
}
Button("Go to Settings") {
path.append(SettingsDestination.general)
}
}
.navigationDestination(for: User.self) { user in
UserDetailView(user: user)
}
.navigationDestination(for: SettingsDestination.self) { destination in
SettingsView(destination: destination)
}
}
}
}
Each .navigationDestination modifier registers a view builder for a specific type. When you append a value to the path, SwiftUI finds the matching destination and pushes that view.
Programmatic Navigation Made Simple
With the path as explicit state, programmatic navigation is just data manipulation:
struct NavigationController: View {
@State private var path = NavigationPath()
var body: some View {
NavigationStack(path: $path) {
HomeView()
.navigationDestination(for: Route.self) { route in
switch route {
case .profile(let userId):
ProfileView(userId: userId)
case .settings:
SettingsView()
case .item(let item):
ItemDetailView(item: item)
}
}
}
.environment(\.navigate, NavigateAction { route in
path.append(route)
})
}
// Pop to root
func popToRoot() {
path = NavigationPath()
}
// Pop one level
func popOne() {
if !path.isEmpty {
path.removeLast()
}
}
// Deep link to specific state
func navigateToItem(_ item: Item) {
path = NavigationPath()
path.append(Route.item(item))
}
}
The popToRoot pattern that required hacks in NavigationView is now a one-liner: reset the path to empty.
Handling Deep Links
Deep linking showcases NavigationStack's power. When your app receives a URL, parse it into navigation state and set the path:
struct AppView: View {
@State private var path = NavigationPath()
var body: some View {
NavigationStack(path: $path) {
RootView()
.navigationDestination(for: DeepLinkDestination.self) { destination in
destinationView(for: destination)
}
}
.onOpenURL { url in
if let destinations = parseDeepLink(url) {
path = NavigationPath()
for destination in destinations {
path.append(destination)
}
}
}
}
}
The entire navigation stack reconstructs from the URL. No coordination between views, no timing issues, no workarounds.
Migrating from NavigationView
For simple cases, migration is mechanical:
// Before
NavigationView {
content
}
// After
NavigationStack {
content
}
For NavigationLink with destinations, switch to value-based navigation:
// Before
NavigationLink(destination: DetailView(item: item)) {
Text(item.name)
}
// After
NavigationLink(value: item) {
Text(item.name)
}
// Plus somewhere up the hierarchy:
.navigationDestination(for: Item.self) { item in
DetailView(item: item)
}
The destination definition moves from the link itself to a modifier on a parent view. This centralizes your navigation logic and makes it easier to maintain.
Split Views with NavigationSplitView
NavigationView with multiple columns became NavigationSplitView, a separate container optimized for iPad and Mac:
struct SplitLayout: View {
@State private var selectedItem: Item?
var body: some View {
NavigationSplitView {
List(items, selection: $selectedItem) { item in
Text(item.name)
}
} detail: {
if let item = selectedItem {
ItemDetailView(item: item)
} else {
ContentUnavailableView("Select an Item", systemImage: "doc")
}
}
}
}
The separation makes intent clearer. NavigationStack is for pushing views onto a stack. NavigationSplitView is for side-by-side column layouts that adapt to screen size.
State Persistence
Since the path is just data, you can persist navigation state across app launches. NavigationPath conforms to Codable when all its contained types do:
struct PersistentNavigation: View {
@State private var path: NavigationPath = loadSavedPath()
var body: some View {
NavigationStack(path: $path) {
RootView()
.navigationDestination(for: Screen.self) { screen in
screenView(for: screen)
}
}
.onChange(of: path) { _, newPath in
savePath(newPath)
}
}
}
Users return to exactly where they left off. This was painful to implement with NavigationView but falls out naturally with NavigationStack.
When to Use Each
Use NavigationStack for new code targeting iOS 16 and later. There's no reason to use the deprecated API when you have a choice.
Keep NavigationView only when you must support iOS 15 or earlier. Even then, consider wrapping it in a compatibility layer so you can migrate later.
The mental model shift (from implicit view-based navigation to explicit data-driven navigation) takes some adjustment. But once it clicks, you'll wonder how you ever lived without it.
// Continue_Learning
SwiftUI vs UIKit: Which Should You Choose in 2026?
A practical guide to choosing between SwiftUI and UIKit in 2026, based on your project requirements, team experience, and the current state of both frameworks.
Presenting Multiple Sheets in SwiftUI
How to present a second sheet from within an already-presented sheet in SwiftUI.
How to force navigation bar to show background in SwiftUI?
Learn how to force the navigation bar to show a background color in SwiftUI.
// Stay Updated
Get notified when I publish new tutorials on Swift, SwiftUI, and iOS development. No spam, unsubscribe anytime.