DEV Community

Sebastien Lato
Sebastien Lato

Posted on

SwiftUI Navigation State Restoration (Cold Launch, Deep Links & Tabs)

Modern SwiftUI apps don’t just navigate — they restore context.

Users expect:

  • the same screen after app relaunch
  • deep links to land precisely
  • tabs to remember their last stack
  • iPad/macOS windows to reopen correctly

Most navigation bugs happen because navigation state is treated as UI, not restorable state.

This post shows how to build robust navigation state restoration in SwiftUI using NavigationStack, path models, and clean app architecture.


🧠 The Core Principle

Navigation is state.

If it matters, it must be serializable.

SwiftUI doesn’t restore views — it restores data that recreates views.


🧱 1. Use Data-Driven Navigation (Always)

Avoid:

NavigationLink("Details", destination: DetailView())
Enter fullscreen mode Exit fullscreen mode

Prefer:

NavigationStack(path: $path) {
    RootView()
}
Enter fullscreen mode Exit fullscreen mode

Where path is typed state.


📦 2. Define a Navigation Route Model

Routes must be:

  • identifiable
  • hashable
  • codable (for persistence)
enum Route: Hashable, Codable {
    case home
    case profile(userID: String)
    case settings
}
Enter fullscreen mode Exit fullscreen mode

This is the single source of truth for navigation.


🧭 3. NavigationStack with a Path

@State private var path: [Route] = []

NavigationStack(path: $path) {
    HomeView()
        .navigationDestination(for: Route.self) { route in
            switch route {
            case .home:
                HomeView()
            case .profile(let id):
                ProfileView(userID: id)
            case .settings:
                SettingsView()
            }
        }
}
Enter fullscreen mode Exit fullscreen mode

Views are now pure functions of state.


💾 4. Persist Navigation State

Store the path whenever it changes:

.onChange(of: path) { newPath in
    savePath(newPath)
}
Enter fullscreen mode Exit fullscreen mode

Example persistence:

func savePath(_ path: [Route]) {
    let data = try? JSONEncoder().encode(path)
    UserDefaults.standard.set(data, forKey: "nav_path")
}
Enter fullscreen mode Exit fullscreen mode

🔄 5. Restore on Launch

func loadPath() -> [Route] {
    guard
        let data = UserDefaults.standard.data(forKey: "nav_path"),
        let path = try? JSONDecoder().decode([Route].self, from: data)
    else {
        return []
    }
    return path
}
Enter fullscreen mode Exit fullscreen mode

Inject it at startup:

@State private var path = loadPath()
Enter fullscreen mode Exit fullscreen mode

Cold launch now restores navigation.


🔗 6. Deep Linking Becomes Trivial

Convert URLs → routes:

func handle(url: URL) {
    switch url.path {
    case "/profile":
        path = [.home, .profile(userID: "123")]
    case "/settings":
        path = [.settings]
    default:
        break
    }
}
Enter fullscreen mode Exit fullscreen mode

No view pushing.
No hacks.
Just state.


📱 7. Tab-Based Navigation Restoration

Each tab owns its own path:

@State private var homePath: [Route] = []
@State private var settingsPath: [Route] = []
Enter fullscreen mode Exit fullscreen mode
TabView {
    NavigationStack(path: $homePath) {
        HomeView()
    }
    .tabItem { Label("Home", systemImage: "house") }

    NavigationStack(path: $settingsPath) {
        SettingsView()
    }
    .tabItem { Label("Settings", systemImage: "gear") }
}
Enter fullscreen mode Exit fullscreen mode

Persist each path separately.


🪟 8. Multi-Window & Scene Restoration

Each window should:

  • have its own navigation state
  • restore independently
  • share global app state
WindowGroup {
    RootView(path: loadPath())
}
Enter fullscreen mode Exit fullscreen mode

Never share navigation paths across windows.


🧪 9. Testing Navigation Restoration

Because routes are data:

func testProfileRestore() {
    let path: [Route] = [.home, .profile(userID: "1")]
    XCTAssertEqual(path.count, 2)
}
Enter fullscreen mode Exit fullscreen mode

You can test:

  • deep links
  • cold launches
  • tab restores
  • multi-window behavior

Without UI tests.


❌ Common Mistakes

Avoid:

  • pushing views imperatively
  • storing views in state
  • relying on NavigationLink side effects
  • mixing navigation & business logic
  • assuming SwiftUI restores views automatically

It doesn’t.


🧠 Mental Model

Think in terms of:

Intent (URL / action)
  Route(s)
  Navigation path
  View reconstruction
Enter fullscreen mode Exit fullscreen mode

Views are results, not drivers.


🚀 Final Thoughts

Once navigation is modeled as serializable state:

  • deep links are easy
  • cold launch works
  • tabs behave correctly
  • multi-window apps scale
  • bugs disappear

Top comments (0)