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.
The Disaster Triggered by NavigationLink
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.
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:
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+NavigationLinkis an edge case, but in practice many other setups can force NavigationLink to pre-build en masse. Take a calendar-style screen built withScrollView+LazyVGridand a flock ofNavigationLinks: if the view jumps straight to the bottom of the scroll container at launch, every child view’sinitfires early, and a stampede ofNavigationLinks gets constructed long before the user ever sees them.
Default Diffing vs. Equatable
SwiftUI’s diffing behavior is heuristic-based rather than strictly rule-based, which explains why the same Equatable conformance may behave differently depending on the view’s structure and context.
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.”
Even though this view defines a == function, ChildView does not explicitly conform to Equatable, so SwiftUI treats it as a non-Equatable view and sticks to its default diffing strategy — meaning it never calls compare(). Consider:
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
}
}Once the view explicitly conforms to Equatable, SwiftUI may use your custom comparison — and in this example, it does:
extension ChildView: Equatable {}Note: If a view conforms to Equatable, SwiftUI may choose to use your custom == implementation, but this is not guaranteed—it depends on SwiftUI’s internal heuristic strategy. For views that contain only ordinary stored properties (such as let or var), SwiftUI may still prefer more efficient strategies—like memory comparison (memcmp) or field-by-field structural comparison—rather than invoking ==. Whether SwiftUI actually uses your custom equality logic depends on several factors, including the view’s property structure, the presence of stateful properties (such as @State or @Binding), and the view’s type characteristics.
Therefore, if you want to ensure that SwiftUI uses your custom == for diffing, you must explicitly apply .equatable() (or wrap the view in EquatableView) to force SwiftUI into the ==-based comparison path.
In the example above, even though .equatable() was not applied, the view’s more complex structure combined with its custom == implementation led SwiftUI’s heuristic system to choose the == path instead of falling back to its default memory-comparison strategy.
More real-world examples: “Say Goodbye to dismiss: A State-Driven Path to More Maintainable SwiftUI” and “How to Avoid Repeating SwiftUI View Updates”.
Blocking NavigationLink Pre-Build with equatable()
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:
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:
- SwiftUI uses an optimized field-by-field diffing strategy to compare the old and new values of a view, rather than relying on a simple memory comparison.
- When a view conforms to the
Equatableprotocol, SwiftUI gains the possibility of using the custom==implementation; however, whether==is actually invoked still depends on SwiftUI’s internal heuristic decisions. - NavigationLink detection SwiftUI appears to notice
NavigationLinkat compile time; once a view is instantiated, it eagerly evaluatesbody. - Pre-build motive The exact rationale is unclear, yet it likely relates to setting up the link/destination pipeline ahead of time.
EquatableViewcloak Applyingequatable()wraps the view, and either hidesNavigationLinkfrom 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!
A special thanks to Heiko, who took the time to revisit this article and highlight subtle but important details about how SwiftUI chooses its diffing strategy. His thoughtful feedback directly contributed to improving this revised version.