DEV Community

Sebastien Lato
Sebastien Lato

Posted on

SwiftUI Navigation Internals: How NavigationStack Really Works

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()
}
Enter fullscreen mode Exit fullscreen mode

Where path is:

var path: [Route]
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

✔ Good:

case profile(id: user.id)
Enter fullscreen mode Exit fullscreen mode

🆔 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
  • @State resets
  • @StateObject resets

This is expected behavior.


🔄 4. Why Navigation Sometimes “Resets”

This code:

path = [.profile(id: "123")]
Enter fullscreen mode Exit fullscreen mode

does two things:

  1. Clears the entire stack
  2. 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"))
Enter fullscreen mode Exit fullscreen mode

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)
}
Enter fullscreen mode Exit fullscreen mode

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())
}
Enter fullscreen mode Exit fullscreen mode

✔ Correct:

.navigationDestination {
    ProfileView(id: id)
}
Enter fullscreen mode Exit fullscreen mode

And let the view own its ViewModel:

@StateObject private var vm = ProfileViewModel(id: id)
Enter fullscreen mode Exit fullscreen mode

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:

  • onAppear may run multiple times
  • onDisappear does NOT mean deallocation
  • .task is 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")]
Enter fullscreen mode Exit fullscreen mode

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:

  1. Did the path change?
  2. Was it replaced or appended?
  3. Did route identity change?
  4. Did a parent view recreate?
  5. 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
Enter fullscreen mode Exit fullscreen mode

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)