← Back to list

Most SwiftUI Apps Still Underbuild NavigationPath: How to Design State Restoration and Deep Link…

As of June 6, 2026, reading Apple’s current NavigationPath, Understanding the navigation stack, Restoring your app's state with SwiftUI…

Hasan Ali Siseci · 2026-06-07 09:15 · 6 claps · 7.4 min read paywalled
#swiftui #navigationpath #navigation-stack #state-restoration #deeplink
Open on Medium ↗
Wiki topics: 📱 · Mobile Development 📚 · Books & Reading

Most SwiftUI Apps Still Underbuild NavigationPath: How to Design State Restoration and Deep Link Flows Correctly

As of June 6, 2026, reading Apple’s current NavigationPath, Understanding the navigation stack, Restoring your app's state with SwiftUI, and iOS & iPadOS 26 release notes together leads to a clear technical conclusion for me: many SwiftUI teams may have adopted NavigationStack, but NavigationPath is still implemented as a half-finished architectural layer in most apps. That gap doesn’t show up in the first demo. It shows up in scene restoration, deep links, post-cold-launch routing, and multiwindow behavior.

NavigationStack has been presented as the modern correct path in SwiftUI for a while now. That part is true. But in practice, many teams have not fully left older instincts behind. We still tend to think about navigation in terms of showing the next screen. A button is tapped, a value is sent, a destination opens, and the work feels done.

I think that is where the real problem begins. Apple’s navigation documentation does not frame NavigationStack as only an API for changing screens. In Understanding the navigation stack, Apple explicitly positions the stack’s state as something exposed to your app through the path parameter, and that path carries not just the current view hierarchy but the data representation of navigation itself. That distinction matters. The moment navigation stops being a view tree and starts being a restorable chain of data, the architecture has to change too.

Why NavigationPath Is Not Just a Collection

Apple’s NavigationPath documentation describes it as a type-erased list. At first glance, that can look like a convenient container for holding heterogeneous values in one stack. That is true, but I do not think that is its main value. Its deeper value is that it separates navigation state from individual SwiftUI views and makes that state portable.

Many projects still carry a familiar bad pattern:

  • Managing navigation with local view state
  • Setting booleans or optional selections manually when a deep link arrives
  • Dropping the user back to root after relaunch
  • Building login routing and normal in-app navigation as two separate systems

That approach works in tiny examples. But as an app grows, navigation stops being one interaction. A route from a push notification, a route from Spotlight or a URL, an in-app list selection, a post-onboarding transition, and a scene restore all begin solving the same problem from different angles. If they do not share a common data representation, the codebase becomes fragile quickly. NavigationPath matters because it lets you carry route data rather than views.

One of Apple’s Most Important Warnings: Don’t Push Models Into the Path

One of the most valuable warnings in Understanding the navigation stack is still underdiscussed in my view: Apple explicitly advises keeping navigation path elements lightweight and avoiding the use of model types as transport for data.

That warning is exactly right. In many SwiftUI projects, the tendency is simple: if a model already represents what the next screen needs, why not push that model directly into the path? It feels convenient at first, but it couples navigation too tightly to the data layer.

The problems start immediately:

  • Large model structures make Hashable and Codable conformance more expensive than it should be
  • Deep links become harder to construct before you have the full model loaded
  • Persistence for state restoration becomes heavier
  • It becomes harder to reason about which identity actually opened a screen

I think the better default is usually this: do not carry the full product model in the path, carry the smallest identifier or enum-like navigation node required for routing. In a recipe app, for example, carrying Route.recipe(id: UUID) is often cleaner than carrying the entire Recipe object. The view can resolve the rest from a repository later. That keeps navigation state and rendering state from sticking together too early.

Choosing Between a Homogeneous Path and NavigationPath Is an Architectural Decision

Apple outlines two directions in the article. If your stack advances using one data type, you can use a standard collection. If you need different kinds of values in the same stack, you can use NavigationPath.

To me, that is not just an API decision. It is an expression of architectural intent. If your routing flow can be represented with a single route type, a homogeneous structure like [Route] is often easier to read and easier to persist, because you can store it directly with Codable. But if the flow spans different node families, separate modules, or multiple destination styles, NavigationPath becomes more flexible.

The mistake is treating heterogeneity as the default just because it is possible. I would design the route type first. If a single route enum can describe most flows, I would not jump to type erasure early. But if independent modules need to append values into the same stack, NavigationPath starts to make more sense.

State Restoration Is the Real Test

The most important part of Apple’s navigation stack article is, in my view, the section on state restoration. Apple is direct there: state restoration for a navigation path helps restore your interface to the previous interaction point during a later launch. Apple also emphasizes that this is especially important on iOS at the window or scene level, because windows come and go frequently.

That is a critical sentence. Many teams still treat restoration as something to revisit later. But in a scene-based lifecycle, unrestored navigation is not just a small UX flaw. In some cases, it means the product behavior is unfinished.

A second iPad window may have been open. A user may have navigated several levels deep into a detail screen. The app may have been rebuilt after memory pressure. A cold launch, a deep link, and a login transition may all collide at once. In those conditions, returning to root is technically easy but product-wise weak.

For Heterogeneous Paths, codable Is the Critical Piece

One of the most practical details in Apple’s documentation is this: for a heterogeneous NavigationPath, you can use path.codable to get a serializable representation, then decode NavigationPath.CodableRepresentation and rebuild the path later.

That is a serious capability. Type-erased structures are usually hardest to persist, and Apple gives a first-party route for it. But Apple also sets an important limit in the same place: if one of the type-erased elements inside the path does not conform to Codable, that representation may not exist.

So the real message is this: design your navigation state not only to be pushable, but to be serializable. That is why route-node design matters from the start.

What a Stronger NavigationStore Can Look Like

In a medium-sized SwiftUI app, I think it is usually better to centralize navigation state in a store than to leave it fragmented across view-level @State values. Once restoration and deep links are part of the same problem, that becomes even more valuable.

import SwiftUI

enum Route: Hashable, Codable {
    case home
    case recipe(id: UUID)
    case author(id: UUID)
}
@MainActor
final class NavigationStore: ObservableObject {
    @Published var path: [Route] = [] {
        didSet { persist() }
    }
    private let fileURL = URL.documentsDirectory.appending(path: "navigation-path.json")
    init() {
        restore()
    }
    func handleDeepLink(_ url: URL) {
        guard let route = RouteParser.route(from: url) else { return }
        path = mergeRestoredPath(with: route)
    }
    private func persist() {
        do {
            let data = try JSONEncoder().encode(path)
            try data.write(to: fileURL, options: [.atomic])
        } catch {
            assertionFailure("Failed to persist navigation path: \\(error)")
        }
    }
    private func restore() {
        guard let data = try? Data(contentsOf: fileURL) else { return }
        guard let restored = try? JSONDecoder().decode([Route].self, from: data) else { return }
        path = restored
    }
    private func mergeRestoredPath(with route: Route) -> [Route] {
        var next = path
        next.append(route)
        return next
    }
}

The important idea here is not the syntax. It is treating navigation as durable state, separated from the view layer.

struct RootView: View {
    @StateObject private var navigationStore = NavigationStore()

    var body: some View {
        NavigationStack(path: $navigationStore.path) {
            HomeScreen()
                .navigationDestination(for: Route.self) { route in
                    switch route {
                    case .home:
                        HomeScreen()
                    case .recipe(let id):
                        RecipeScreen(recipeID: id)
                    case .author(let id):
                        AuthorScreen(authorID: id)
                    }
                }
                .onOpenURL { url in
                    navigationStore.handleDeepLink(url)
                }
        }
    }
}

This is not perfect, but it does move deep links, in-app navigation, and restore behavior closer to one shared data model.

Deep Link Handling Should Not Fight Restoration

I think one of the most hidden navigation bugs in many apps lives here. When the app opens, there may be two competing intents: the last restored path from disk and a brand new incoming deep link. If the architecture does not treat both as part of the same system, one will start overwriting the other.

The approach I like is to treat a deep link not as a direct command to show a screen, but as a route transformation applied to the current navigation state. Sometimes the restored path should be preserved and a new route appended. Sometimes product or security rules require resetting the stack and building a new root flow. What matters is having that policy in one place.

That is why appending ad hoc inside onOpenURL is usually weaker than defining deep-link policy and restoration policy inside the same store.

SwiftUI Does Not Solve the Entire Problem for You

Another important point in Apple’s article is that SwiftUI tracks navigation state and the contents of the path, but it does not give your app stateful hooks indicating when the system pushes a view. Apple also explicitly notes that view-based destinations cannot be restored programmatically.

That warning matters. There is sometimes an implicit expectation that if SwiftUI knows the stack, it will handle the rest. It will not. SwiftUI gives you a strong data-driven navigation foundation. But designing a restorable, testable, deep-link-compatible navigation flow is still the app architecture’s job.

That is why I think value-destination-driven navigation is often the safer path. Data is easier to save, rebuild, and test than view state.

What I Would Inspect in a SwiftUI Codebase Today

First, I would check whether navigation state is fragmented across views or tied to a central route representation. Second, I would inspect whether path elements are truly lightweight or whether large model objects are being pushed directly. Third, I would check whether restoration and deep-link flows live in the same store or whether two unrelated routing mechanisms are coexisting.

Fourth, I would look for unnecessary NavigationPath usage where a homogeneous route array would be enough, because type erasure is not always power; sometimes it is just ambiguity. Fifth, I would test cold launch, relaunch, second-window behavior, and post-login routing on real devices to see whether the path is rebuilt deterministically.

My Conclusion as of June 6, 2026

NavigationPath is, in my view, one of the easiest SwiftUI APIs to underestimate while also being one of the most important for product quality. If you see it only as a handy box for heterogeneous navigation, you get half the benefit. If you treat it as a scene-restoration, deep-link, and data-driven routing layer, SwiftUI navigation becomes much calmer and much more defensible.

So if I were on an iOS team today, I would not consider “we migrated to NavigationStack” to be enough. My real question would be this: is the app’s navigation state actually designed to be restorable, serializable, and externally routable?


메타데이터
post_id
507d46c196ff
slug
most-swiftui-apps-still-underbuild-navigationpath-how-to-design-state-restoration-and-deep-link-507d46c196ff
url
https://medium.com/@hasanalidev/most-swiftui-apps-still-underbuild-navigationpath-how-to-design-state-restoration-and-deep-link-507d46c196ff
canonical_url
https://medium.com/@hasanalidev/most-swiftui-apps-still-underbuild-navigationpath-how-to-design-state-restoration-and-deep-link-507d46c196ff
author_url
https://medium.com/@hasanalidev
status
ok
fetched_at
2026-08-27 20:45:58