Home

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.