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())
Prefer:
NavigationStack(path: $path) {
RootView()
}
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
}
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()
}
}
}
Views are now pure functions of state.
💾 4. Persist Navigation State
Store the path whenever it changes:
.onChange(of: path) { newPath in
savePath(newPath)
}
Example persistence:
func savePath(_ path: [Route]) {
let data = try? JSONEncoder().encode(path)
UserDefaults.standard.set(data, forKey: "nav_path")
}
🔄 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
}
Inject it at startup:
@State private var path = loadPath()
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
}
}
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] = []
TabView {
NavigationStack(path: $homePath) {
HomeView()
}
.tabItem { Label("Home", systemImage: "house") }
NavigationStack(path: $settingsPath) {
SettingsView()
}
.tabItem { Label("Settings", systemImage: "gear") }
}
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())
}
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)
}
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
NavigationLinkside 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
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)