SwiftUI navigation looks simple on the surface — until it isn’t.
That’s when you see:
- 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 the same:
Not understanding how SwiftUI navigation actually works internally.
This post explains NavigationStack from the inside out — identity, path diffing, lifecycle, and state — using the modern SwiftUI model.
🧠 The Core Truth About SwiftUI Navigation
SwiftUI navigation is state-driven, not imperative.
You are not “pushing views”.
You are:
Mutating navigation state
→ SwiftUI diffs the path
→ SwiftUI rebuilds the stack
→ SwiftUI decides what stays alive
Navigation is just data.
🧭 1. NavigationStack Is a State Machine
At its core:
NavigationStack(path: $path) {
RootView()
}
Where path is:
var path: [Route]
SwiftUI:
- compares the old path
- compares the new path
- computes the difference
- applies insertions/removals
There is no push API.
Only state mutation.
🧩 2. Routes Must Be Value Types With Stable Identity
enum Route: Hashable {
case profile(id: String)
case settings
}
Why this matters:
- SwiftUI hashes routes
- identity is derived from the value
- unstable values = broken navigation
❌ Bad:
case profile(id: UUID()) // new identity every time
✔ Good:
case profile(id: user.id)
🆔 3. Navigation Identity Is NOT View Identity
Important distinction:
- Route identity → controls navigation stack
- View identity → controls state preservation
If the route changes:
- SwiftUI removes the destination
- view identity is destroyed
-
@Stateresets -
@StateObjectresets
This is expected behavior.
🔄 4. Why Navigation Sometimes “Resets”
This code:
path = [.profile(id: "123")]
does two things:
- Clears the entire stack
- Pushes a new destination
Which means:
- every intermediate view is destroyed
- state is lost
- animations restart
If you want to append instead:
path.append(.profile(id: "123"))
Navigation bugs often come from accidentally replacing the path.
⚠️ 5. navigationDestination Is a Factory, Not Storage
.navigationDestination(for: Route.self) { route in
ProfileView(id: route.id)
}
This closure:
- is called multiple times
- does not persist views
- does not guarantee a single instance
Never store state here.
State must live in:
- the ViewModel
- AppState
- parent scope
🧱 6. ViewModels & Navigation: The Correct Pattern
❌ Wrong:
.navigationDestination {
ProfileView(vm: ProfileViewModel())
}
✔ Correct:
.navigationDestination {
ProfileView(id: id)
}
And let the view own its ViewModel:
@StateObject private var vm = ProfileViewModel(id: id)
Or inject a stable instance from above.
🧠 7. Navigation + Lifecycle = Predictable State
Navigation does this internally:
- push → view created
- pop → view destroyed
- re-push → brand new identity
So:
-
onAppearmay run multiple times -
onDisappeardoes NOT mean deallocation -
.taskis cancelled automatically
This is why .task is preferred over onAppear.
🔗 8. Deep Links Are Just Path Mutations
Deep linking is simply:
path = [.profile(id: "999")]
No special APIs needed.
The same rules apply:
- identity
- state reset
- lifecycle restart
Which is why deep linking + AppState works so well.
🧪 9. Debugging Navigation Bugs
Ask yourself:
- Did the path change?
- Was it replaced or appended?
- Did route identity change?
- Did a parent view recreate?
- Was state owned by the view or lifted up?
Navigation bugs are almost always state bugs.
🧠 Mental Model Cheat Sheet
NavigationStack
↓
Path (data)
↓
Diff
↓
View lifecycle
↓
State preserved or destroyed
Once you see navigation as data diffing, everything clicks.
🚀 Final Thoughts
SwiftUI navigation is:
- deterministic
- state-driven
- identity-sensitive
It only feels “buggy” when the mental model is wrong.
Once you understand:
- path diffing
- route identity
- view lifecycle
- state ownership
Navigation becomes predictable, testable, and scalable — even in very large apps.
Top comments (0)