SwiftUI gets stuck in a view update cycle when I pass down a Binding to a NavigationPath

Hey, I have a situation where the app freezes (because it's stuck in an endless update cycle) when I pass down a binding to a NavigationPath.

I have a simple example to reproduce the behavior. Copy the following code and run it in previews, a simulator or a real device. Just tap "Show Middle View" and then "Show Inner View", which will cause the app to freeze, because SwiftUI gets stuck in an update cycle:

import SwiftUI

// This is literally empty
@MainActor final class SomeEnvironmentObject: ObservableObject {}

@MainActor
final class Router: ObservableObject {
	@Published var path: NavigationPath = .init()
}

struct ContentView: View {
	@StateObject var router = Router()

	var body: some View {
		NavigationStack(path: $router.path) {
			Button("Show Middle View") {
				router.path.append(0)
			}
			.navigationDestination(for: Int.self) { destination in
				MiddleView(path: $router.path)
			}
		}
	}
}

struct MiddleView: View {
	@EnvironmentObject var someEnvironmentObject: SomeEnvironmentObject

	@Binding var path: NavigationPath
	var body: some View {
		Button("Show Inner View") {
			path.append("0")
		}
		.navigationDestination(for: String.self) { destination in
			InnerView(path: $path)
		}
	}
}

struct InnerView: View {
	@Binding var path: NavigationPath
	var body: some View {
		Text("Inner View")
	}
}

#Preview {
	ContentView()
		.environmentObject(SomeEnvironmentObject())
}

The strange thing is, that the app freezes only, when MiddleView has the environment object:

struct MiddleView: View {
  @EnvironmentObject var someEnvironmentObject: SomeEnvironmentObject
  // ...
}

Removing that line makes everything work.

I'm pretty sure this is some kind of navigation dependency problem, where I'm capturing too much in the navigationDestination modifier, causing SwiftUI to repeatedly update the views in a cycle. I've read about something similar here: https://hachyderm.io/@teissler/112533860374716961

However, I've tried a variety of combinations of only capturing stuff that's needed in the navigationDestination closure, but nothing works. It still freezes up.

Does anyone have an idea? I assume it's an error on my side, but maybe it could be a bug in SwiftUI? I have no idea how to solve this.


This problem occurs on Xcode 16.0 Beta 5, 16.1 Beta 1 and 15.4, as well as on iOS 17 and iOS 18

You set

.environmentObject(SomeEnvironmentObject())

in Preview.

Do you do the same for the app where you call ContentView() ?

When using ObservableObject for such purposes, when passing a navigation path from it to NavigationStack, there is such a problem that there are many unnecessary calls to navigationDestination and unnecessary reinitialization of View from the entire path https://feedbackassistant.apple.com/feedback/14536210

Additionally, due to the presence of EnvironmentObject, most likely some operations occur that lead to a loop of this process.

Also, it is better to move navigationDestination as close to NavigationStack as possible, i.e. it is better to move it from MiddleView to ContentView. An example according to your code:

// This is literally empty
@MainActor final class SomeEnvironmentObject: ObservableObject {}

@MainActor
final class Router: ObservableObject {
    @Published var path: NavigationPath = .init()
}

struct ContentView: View {
    @StateObject var router = Router()
    @State private var someEnvironmentObject = SomeEnvironmentObject()

    var body: some View {
        NavigationStack(path: $router.path) {
            Button("Show Middle View") {
                router.path.append(0)
            }
            .navigationDestination(for: Int.self) { destination in
                MiddleView(path: $router.path)
            }
            .navigationDestination(for: String.self) { destination in
                InnerView(path: $router.path)
            }
        }
        .environmentObject(someEnvironmentObject)
    }
}

struct MiddleView: View {
    @EnvironmentObject var someEnvironmentObject: SomeEnvironmentObject

    @Binding var path: NavigationPath
    var body: some View {
        Button("Show Inner View \(someEnvironmentObject.self)") {
            path.append("0")
        }
    }
}

struct InnerView: View {
    @Binding var path: NavigationPath
    var body: some View {
        Text("Inner View")
    }
}

But if you want to leave it the same, you can use the @State variable for the navigation path, which is synchronized with the path from the Navigator, this will also fix the problem for this particular case, and will reduce the number of unnecessary navigationDestination calls and reinitializations to zero (not on all OS versions), example:

@main
struct ExampleApp: App {
    @State var router = Router()

    var body: some Scene {
        WindowGroup {
            ContentView()
                .environmentObject(router)
        }
    }
}

// This is literally empty
@MainActor final class SomeEnvironmentObject: ObservableObject {}

@MainActor
final class Router: ObservableObject {
    @Published var path: NavigationPath = .init()
}

struct ContentView: View {
    @State private var someEnvironmentObject = SomeEnvironmentObject()
    @State private var path = NavigationPath()

    var body: some View {
        NavigationStack(path: $path) {
            Button("Show Middle View") {
                path.append(0)
            }
            .navigationDestination(for: Int.self) { destination in
                MiddleView(path: $path)
            }
        }
        .environmentObject(someEnvironmentObject)
        .synchronize(path: $path)
    }
}

extension View {

    // Or pass Binding navigation path from Navigator in a parameter
    func synchronize(path: Binding<NavigationPath>) -> some View {
        modifier(NavigationPathSynchronizationModifier(path: path))
    }
}

struct NavigationPathSynchronizationModifier: ViewModifier {
    @EnvironmentObject var router: Router // Or pass navigator path directly as a @Binding
    @Binding var path: NavigationPath

    func body(content: Content) -> some View {
        if #available(iOS 17.0, macOS 14.0, tvOS 17.0, watchOS 10.0, *) {
            content
                .onChange(of: path) { _, newValue in
                    guard router.path != newValue else { return }

                    router.path = newValue
                }
                .onChange(of: router.path) { _, newValue in
                    guard path != newValue else { return }

                    path = newValue
                }
        } else {
            content
                .onChange(of: path) { newValue in
                    guard router.path != newValue else { return }

                    router.path = newValue
                }
                .onChange(of: router.path) { newValue in
                    guard path != newValue else { return }

                    path = newValue
                }
        }
    }
}


struct MiddleView: View {
    @EnvironmentObject var someEnvironmentObject: SomeEnvironmentObject

    @Binding var path: NavigationPath
    var body: some View {
        Button("Show Inner View \(someEnvironmentObject.self)") {
            path.append("0")
        }
        .navigationDestination(for: String.self) { destination in
            InnerView(path: $path)
        }
    }
}

struct InnerView: View {
    @Binding var path: NavigationPath
    var body: some View {
        Text("Inner View")
    }
}

Thanks for the answer 🙂


When using ObservableObject for such purposes, when passing a navigation path from it to NavigationStack, there is such a problem that there are many unnecessary calls to navigationDestination and unnecessary reinitialization of View from the entire path https://feedbackassistant.apple.com/feedback/14536210

Yeah, that's true. Though that only really happens when the navigation state changes, which doesn't happen too often (since the ObservableRouter is just a Router, not a view model holding lots of states), and most of the times, the paths aren't that deep for it to have a huge impact, I think. And with @Observable unnecessary redraws should occur even less.


Additionally, due to the presence of EnvironmentObject, most likely some operations occur that lead to a loop of this process.

Yep, I'd love to understand what that loop actually looks like. That's really tricky to find out, unfortunately.


Also, it is better to move navigationDestination as close to NavigationStack as possible, i.e. it is better to move it from MiddleView to ContentView. An example according to your code:

Is that the case? I've never heard that before 🤔Do you have a link where this is stated/explained? That would make me rethink some of the architectural decisions in my app. I assumed the entire purpose of that modifier is to place it anywhere in a NavigationStack, even on sub-pages.

The problem with moving everything to ContentView is that you lose all modularity. ContentView would be tightly coupled with MiddleView, which is what I really want to avoid.


But if you want to leave it the same, you can use the @State variable for the navigation path, which is synchronized with the path from the Navigator, this will also fix the problem for this particular case, and will reduce the number of unnecessary navigationDestination calls and reinitializations to zero (not on all OS versions), example:

I've seen this approach a few times. It does work, yeah, but I don't like it very much, to be honest. It kind of defeats the point of SwiftUI and its state mechanisms. This manual synchronisation is exactly what declarative code is trying to get rid of.

Is that the case? I've never heard that before 🤔Do you have a link where this is stated/explained? That would make me rethink some of the architectural decisions in my app. I assumed the entire purpose of that modifier is to place it anywhere in a NavigationStack, even on sub-pages.

I often encountered similar statements when searching for solutions to problems with NavigationStack. For example, here is the first link that I found now with the answer from Apple Engineer: https://forums.developer.apple.com/forums/thread/727307?answerId=749141022#749141022

Quote from the answer:

move navigationDestination modifiers as high up in the view hierarchy. This is more efficient for the Navigation system to read up front than with potentially every view update.


The problem with moving everything to ContentView is that you lose all modularity. ContentView would be tightly coupled with MiddleView, which is what I really want to avoid.

After workarounding a number of problems with NavigationStack, I came to the following solution for myself, maybe it may be useful:

  • do not use NavigationPath, only an array (depending on the OS version, there were different problems).
  • place navigationDestination on the root view of NavigationStack.
  • use a synchronized @State variable for the path (yes, as you said, would not like to use this, but the absence of unnecessary re-initializations / body calls calms me down)

To solve the problem with modularization, you can use ViewBuilder or View for different parts of the path.

Simplified example:

import SwiftUI

enum Destination: Hashable {
    case flow1(Flow1Destination)
    case flow2(Flow2Destination)
}

struct ContentView: View {
    @State var path: [Destination] = []

    var body: some View {
        NavigationStack(path: $path) {
            RootView(path: $path)
                .navigationDestination(for: Destination.self) { destination in
                    switch destination {
                    case let .flow1(destination):
                        Flow1FactoryView(destination: destination, path: $path, getDestination: { .flow1($0) })
                    case let .flow2(destination):
                        Flow2FactoryView(destination: destination, path: $path, getDestination: { .flow2($0) })
                    }
                }
        }
    }
}

struct RootView: View {
    @Binding var path: [Destination]

    var body: some View {
        VStack {
            Button("Flow1") {
                path.append(.flow1(.details))
            }
            Button("Flow2") {
                path.append(.flow2(.login))
            }
        }
        .navigationTitle("Root")
    }
}

enum Flow1Destination: Hashable {
    case details
    case more
}

struct Flow1FactoryView<Destination: Hashable>: View {
    let destination: Flow1Destination
    @Binding var path: [Destination]
    let getDestination: (Flow1Destination) -> Destination

    var body: some View {
        switch destination {
        case .details: DetailsView(onShowMore: { path.append(getDestination(.more)) })
        case .more: MoreView()
        }
    }
}

struct DetailsView: View {
    let onShowMore: () -> Void

    var body: some View {
        Button("Show more", action: onShowMore)
    }
}

struct MoreView: View {
    var body: some View {
        Text("No more")
    }
}

enum Flow2Destination: Hashable {
    case login
    case forgot
}

struct Flow2FactoryView<Destination: Hashable>: View {
    let destination: Flow2Destination
    @Binding var path: [Destination]
    let getDestination: (Flow2Destination) -> Destination

    var body: some View {
        switch destination {
        case .login: LoginView(onForgotPassword: { path.append(getDestination(.forgot)) })
        case .forgot: ForgotView(onClose: { path.removeLast() })
        }
    }
}

struct LoginView: View {
    let onForgotPassword: () -> Void

    var body: some View {
        VStack {
            Text("Login")
            Button("Forgot?", action: onForgotPassword)
        }
    }
}

struct ForgotView: View {
    let onClose: () -> Void

    var body: some View {
        Button("Forgot", action: onClose)
    }
}

#Preview {
    ContentView()
}
SwiftUI gets stuck in a view update cycle when I pass down a Binding to a NavigationPath
 
 
Q