Using equatable() to Avoid the NavigationLink Pre-Build Pitfall

Published on

NavigationLink is a component SwiftUI developers love. By ingeniously combining the behavior of Button with navigation logic, it dramatically simplifies code. Unfortunately, in certain scenarios, using it the wrong way can create serious performance issues and make your app sluggish. This article analyzes the cause of the problem and offers a practical—albeit slightly mysterious—solution: adding the equatable() modifier to optimize performance.

Inside a SwiftUI List, applying the id modifier to a child view breaks the list’s built-in optimization. At first render SwiftUI instantiates every child view that bears an id (all their init methods run), but it calls body only for rows currently visible on screen.

Read “Tips and Considerations for Using Lazy Containers in SwiftUI” for more details.

Although combining id with List is harmful, we can’t always avoid it. Normally that is tolerable, because creating a SwiftUI view struct is lightweight; the extra initializations are often acceptable.

However, once we drop a NavigationLink into the same scenario, everything deteriorates rapidly.

NavigationLink has an irritating default: it is pre-built. After a view instance is created, SwiftUI immediately evaluates its child view’s body. In the example below, even though all NavigationLinks live inside a lazy container (List), SwiftUI constructs all child views in one go (triggering both init and body), leading to severe hitching.

Swift
struct DemoRootView: View {
    var body: some View {
        NavigationStack {
            List(0 ..< 10_000) { i in
                LinkView(i: i)
                    .id(i)  // all LinkView inits fire
            }
        }
    }
}

struct LinkView: View {
    let i: Int
    init(i: Int) {
        self.i = i
        print("init \(i)")
    }

    var body: some View {
        let _ = print("update \(i)")
        NavigationLink(value: i) {  // all LinkView bodies fire
            Text("\(i)")
        }
    }
}

Obviously this is unacceptable. To avoid pre-building, many developers jettison NavigationLink entirely and handle navigation manually:

Swift
struct DemoRootView: View {
    @State private var path = NavigationPath()
    var body: some View {
        NavigationStack(path: $path) {
            List(0 ..< 10_000) { i in
                LinkView(i: i, path: $path)
                    .id(i)  // all LinkView inits fire
            }
            .navigationDestination(for: Int.self) {
                Text("\($0)")
            }
        }
    }
}

struct LinkView: View {
    let i: Int
    @Binding var path: NavigationPath
    init(i: Int, path: Binding<NavigationPath>) {
        self.i = i
        _path = path
        print("init \(i)")
    }

    var body: some View {
        let _ = print("update \(i)")
        Button {   // replace NavigationLink
            path.append(i)
        } label: {
            Text("\(i)")
        }
    }
}

But swapping NavigationLink out for Button sacrifices the default button styling and interactive feedback—hardly ideal. Is there a best-of-both-worlds solution?

By happy accident I discovered one: keep NavigationLink, yet stop the pre-evaluation of invisible rows by wrapping the row in .equatable().

Some readers may feel that the exact recipe of List + id + NavigationLink is an edge case, but in practice many other setups can force NavigationLink to pre-build en masse. Take a calendar-style screen built with ScrollView + LazyVGrid and a flock of NavigationLinks: if the view jumps straight to the bottom of the scroll container at launch, every child view’s init fires early, and a stampede of NavigationLinks gets constructed long before the user ever sees them.

Default Diffing vs. Equatable

Before diving into equatable(), we need a quick recap of SwiftUI’s diffing strategy.

Whenever a parent view updates (i.e., recomputes its body), SwiftUI builds a fresh child-view instance and quickly compares it with the previous instance to decide whether to recurse into it. SwiftUI’s built-in diff is a field-by-field comparison that’s extremely fast; hence views are not required to adopt the Equatable protocol.

For a deeper explanation, see “Understanding SwiftUI’s View Update Mechanism: Starting from a TimelineView Update Issue.”

If a view conforms to Equatable, SwiftUI drops its default heuristic and uses your custom == instead. Consider:

Swift
struct RootView: View {
    @State private var i = 0
    var body: some View {
        VStack {
            ChildView(i: i, name: "fat")
            Button("i++") { i += 1 }
        }
    }
}

struct ChildView: View {
    let i: Int
    let name: String
    init(i: Int, name: String) {
        self.i = i
        self.name = name
        print("init \(i)")
    }

    var body: some View {
        Text("Child View \(i)")
    }

    // Comparison function *without* declaring Equatable
    static func == (lhs: Self, rhs: Self) -> Bool {
        print("compare")
        return lhs.i == rhs.i
    }
}

Because ChildView does not actually conform to Equatable, SwiftUI sticks to its default diffing and never calls compare().

Add conformance and SwiftUI switches to your comparison:

Swift
extension ChildView: Equatable {}

Caveat: if a view has only one stored property, SwiftUI continues to rely on its native diff even when the type is Equatable. Reasonable enough—there’s nothing to gain by handing control to a custom comparison that checks a single field.

So, apart from the single-parameter special case, declaring a view Equatable prompts SwiftUI to adopt your comparison logic.

More real-world examples: “Say Goodbye to dismiss: A State-Driven Path to More Maintainable SwiftUI” and “How to Avoid Repeating SwiftUI View Updates”.

In early SwiftUI versions you had to call .equatable() on a view that already conformed to Equatable to activate the custom diff. Recent versions usually detect conformance automatically, leading many to wonder whether the modifier still matters.

Apple’s docs say equatable() wraps the view inside an EquatableView:

Prevents the view from updating its child view when its new value is the same as its old value.
nonisolated func equatable() -> EquatableView<Self>

Although Apple never explains EquatableView in detail, it absolutely prevents NavigationLink from pre-building the child view. Witness:

Swift
struct DemoRootView: View {
    @State private var path = NavigationPath()
    var body: some View {
        NavigationStack(path: $path) {
            List(0 ..< 10_000) { i in
                LinkView(i: i)
                    .equatable()                  // stops pre-build
                    .id(i)                       // all inits fire, bodies don't
            }
            .navigationDestination(for: Int.self) {
                Text("\($0)")
            }
        }
    }
}

struct LinkView: View, Equatable {                // must be Equatable
    let i: Int
    init(i: Int) {
        self.i = i
        print("init \(i)")
    }

    var body: some View {
        let _ = print("update \(i)")
        NavigationLink(value: i) {
            Text("\(i)")
        }
    }
}

Invisible rows now skip their body evaluation, and scrolling stays smooth.

Why Does This Work? 🤔

I haven’t fully unraveled the secret sauce, but current evidence suggests:

  1. Default diff SwiftUI performs an efficient, field-by-field comparison.
  2. Equatable switch Declaring conformance replaces that with your ==.
  3. NavigationLink detection SwiftUI appears to notice NavigationLink at compile time; once a view is instantiated, it eagerly evaluates body.
  4. Pre-build motive The exact rationale is unclear, yet it likely relates to setting up the link/destination pipeline ahead of time.
  5. EquatableView cloak Applying equatable() wraps the view, and either hides NavigationLink from SwiftUI’s pre-build scanner or explicitly blocks the evaluation.

We haven’t exposed every cog, but the remedy is effective—and that’s what matters on ship-day.

A Ledger Still in the Fog

In an era of AI-assisted coding, SwiftUI remains strangely “pure.” Because the framework is closed-source, many inner workings defy tidy documentation; we rely heavily on experience and intuition to solve issues.

For a precision-minded profession that’s far from ideal, yet the uncertainty forms a firewall—making this niche just a bit harder for AI to conquer 😂.

Happy coding, and may all your NavigationLinks stay perfectly lazy!

Weekly Swift & SwiftUI highlights!