SwiftUI Navigation Internals: How NavigationStack Really Works
Source: Dev.to
Overview
SwiftUI navigation looks simple on the surface—until it isn’t. Common symptoms include:
- Views recreating unexpectedly
- Navigation stacks resetting
- Back buttons disappearing
- State getting lost when pushing
- Deep links behaving inconsistently
- Performance degrading in large flows
The root cause is almost always a misunderstanding of how SwiftUI navigation works internally. This article explains NavigationStack from the inside out—identity, path diffing, lifecycle, and state—using the modern SwiftUI model.
NavigationStack Basics
SwiftUI navigation is state‑driven, not imperative. You don’t “push views”; you provide navigation data.
NavigationStack(path: $path) {
RootView()
}
path is simply an array of routes:
var path: [Route]
SwiftUI:
- Compares the old path with the new path
- Computes the difference
- Applies insertions/removals
There is no explicit push API.
Defining Routes
enum Route: Hashable {
case profile(id: String)
case settings
}
Route Identity
SwiftUI hashes routes; the identity of a route controls the navigation stack. Unstable values lead to broken navigation.
Bad example (new identity each time):
case profile(id: UUID())
Good example (stable identity):
case profile(id: user.id)
View Identity vs. Route Identity
- Route identity → controls navigation stack
- View identity → controls state preservation
If the route changes, SwiftUI removes the destination, destroying its view identity. Consequently, @State and @StateObject are reset. This is expected behavior.
Managing the Path
Setting the path replaces the entire stack:
path = [.profile(id: "123")]
This clears the stack, pushes a new destination, destroys intermediate views, and resets their state and animations.
To append instead:
path.append(.profile(id: "123"))
Navigation Destination
.navigationDestination(for: Route.self) { route in
ProfileView(id: route.id)
}
- The closure is called multiple times.
- It does not persist views.
- Do not store state here.
Correct State Placement
.navigationDestination {
ProfileView(id: id)
}
Inside ProfileView:
@StateObject private var vm = ProfileViewModel(id: id)
Or inject a stable instance from a parent scope (e.g., ViewModel, AppState).
Lifecycle Implications
Internally:
- Push → view created
- Pop → view destroyed
- Re‑push → brand‑new identity
Therefore:
onAppearmay run multiple times.onDisappeardoes not guarantee deallocation..taskis cancelled automatically, making it preferable toonAppearfor side‑effects.
Deep Linking
Deep linking is just another path assignment:
path = [.profile(id: "999")]
No special APIs are required. The same rules about identity, state reset, and lifecycle apply, which is why deep linking works well when combined with a central AppState.
Debugging Checklist
- Did the path change?
- Was it replaced or appended?
- Did the route identity change?
- Did a parent view recreate?
- Was state owned by the view or lifted up?
Most navigation bugs are actually state‑management bugs.
Conceptual Flow
NavigationStack
↓
Path (data)
↓
Diff
↓
View lifecycle
↓
State preserved or destroyed
Conclusion
When you view navigation as data diffing, everything clicks. SwiftUI navigation is:
- Deterministic
- State‑driven
- Identity‑sensitive
It only feels “buggy” when the mental model is wrong. Understanding path diffing, route identity, view lifecycle, and state ownership makes navigation predictable, testable, and scalable—even in very large apps.