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 NavigationLink
s 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
+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 withScrollView
+LazyVGrid
and a flock ofNavigationLink
s: if the view jumps straight to the bottom of the scroll container at launch, every child view’sinit
fires early, and a stampede ofNavigationLink
s 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:
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:
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”.
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:
- Default diff SwiftUI performs an efficient, field-by-field comparison.
Equatable
switch Declaring conformance replaces that with your==
.- NavigationLink detection SwiftUI appears to notice
NavigationLink
at 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.
EquatableView
cloak Applyingequatable()
wraps the view, and either hidesNavigationLink
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 NavigationLink
s stay perfectly lazy!