NavigationStack with a typed path
NavigationStack replaced NavigationView, and the important change is not the name. Navigation
state became a value you own rather than something the framework hides, which makes deep linking
and state restoration ordinary rather than a fight.
The basic shape
enum Route: Hashable {
case bookDetail(Book.ID)
case authorProfile(Author.ID)
case settings
}
struct RootView: View {
@State private var path: [Route] = []
var body: some View {
NavigationStack(path: $path) {
LibraryView()
.navigationDestination(for: Route.self) { route in
switch route {
case .bookDetail(let id): BookDetailView(id: id)
case .authorProfile(let id): AuthorProfileView(id: id)
case .settings: SettingsView()
}
}
}
}
}
The array is the navigation stack. Appending pushes, removing pops, and setting it replaces the whole stack.
path.append(.bookDetail(book.id)) // push
path.removeLast() // pop
path.removeAll() // pop to root
path = [.settings, .bookDetail(id)] // replace the whole stack
That last one is the capability that did not exist before, and it is what makes deep links work.
Why an enum rather than the type-based API
navigationDestination(for: Book.self) also works, and I stopped using it. Two reasons.
One destination per type. If two different screens both push a Book, you cannot express that —
the destination is keyed by type. An enum lets .bookDetail(id) and .bookEditor(id) coexist.
IDs rather than models. Pushing a Book puts the whole model in the navigation path. If that
book is edited elsewhere, the pushed copy is stale, and if the path is persisted, you are persisting
a model snapshot. Pushing Book.ID and fetching in the destination avoids both.
Warning
Every type in the path must be Hashable, and if you persist the path it must be Codable too.
That is a strong argument for an enum of IDs — a model type that gains a non-Codable property
silently breaks restoration, and the failure is “the app opens at the root screen” rather than an
error.
Deep linking
Because the path is a value, a deep link is an assignment:
.onOpenURL { url in
guard let route = Route(url: url) else { return }
path = [route] // or append, to preserve context
}
Whether to replace or append is a product decision — a notification tap usually replaces, an in-app link usually appends. Both are one line, which is the point.
State restoration
SceneStorage persists the path across launches:
struct RootView: View {
@SceneStorage("navigationPath") private var pathData: Data?
@State private var path: [Route] = []
var body: some View {
NavigationStack(path: $path) { … }
.task {
if let pathData, let restored = try? JSONDecoder().decode([Route].self, from: pathData) {
path = restored
}
}
.onChange(of: path) {
pathData = try? JSONEncoder().encode(path)
}
}
}
The user closes the app three screens deep and comes back to exactly that screen. In NavigationView
this was essentially impossible.
Worth guarding the restore: if a route refers to something that has since been deleted, the destination will fail to load. Validating routes before restoring, and dropping invalid ones, is worth the ten lines.
Putting the path in a model
Once more than one view needs to navigate, hold the path in an @Observable object:
@Observable
final class Navigator {
var path: [Route] = []
func push(_ route: Route) { path.append(route) }
func popToRoot() { path.removeAll() }
func replace(with routes: [Route]) { path = routes }
}
Injected through the environment, any view can navigate without a binding threaded down:
@Environment(Navigator.self) private var navigator
Button("Open settings") { navigator.push(.settings) }
This is the piece that makes navigation testable. navigator.push(.settings) followed by asserting
navigator.path == [.settings] is an ordinary unit test, with no UI involved.
Two things that still catch me
NavigationLink(value:), not NavigationLink(destination:). The value form appends to the path;
the destination form creates an untracked push that the path knows nothing about. Mixing them means
popToRoot does not pop everything.
navigationDestination must be inside the stack, attached to a view in the stack’s content — not
to the NavigationStack itself. Placing it outside compiles fine and does nothing at runtime, which
is a confusing half-hour.