SwiftUI
NavigationLink
destination view
loading issue
iOS development

SwiftUI NavigationLink loads destination view immediately, without clicking

Master System Design with Codemia

Enhance your system design skills with over 120 practice problems, detailed solutions, and hands-on exercises.

Introduction

NavigationLink in SwiftUI initializes its destination view immediately when the parent view renders, not when the user taps the link. This means the destination's init() runs, network requests fire, and resources are allocated before the user ever navigates. This is by design. SwiftUI pre-renders views for transition animations. The fix is to wrap the destination in a lazy container or use the NavigationLink(value:) API introduced in iOS 16.

The Problem

swift
1struct ContentView: View {
2    var body: some View {
3        NavigationView {
4            List(0..<10) { index in
5                NavigationLink("Item \(index)") {
6                    DetailView(id: index)  // init() called immediately for ALL 10
7                }
8            }
9        }
10    }
11}
12
13struct DetailView: View {
14    let id: Int
15
16    init(id: Int) {
17        print("DetailView init: \(id)")  // Prints 0-9 immediately on appear!
18        // Any expensive work here runs for every row, not just the tapped one
19    }
20
21    var body: some View {
22        Text("Detail \(id)")
23    }
24}

When ContentView appears, all 10 DetailView initializers run. If DetailView makes API calls in init(), you get 10 unnecessary network requests.

Fix 1: Lazy Destination Wrapper (iOS 13+)

Create a wrapper that defers view creation until navigation occurs:

swift
1struct LazyView<Content: View>: View {
2    let build: () -> Content
3
4    init(_ build: @autoclosure @escaping () -> Content) {
5        self.build = build
6    }
7
8    var body: some View {
9        build()
10    }
11}
12
13// Usage
14NavigationLink("Item \(index)") {
15    LazyView(DetailView(id: index))
16}

The @autoclosure captures the DetailView(id: index) expression without evaluating it. The view is only created when SwiftUI renders LazyView.body, which happens during navigation.

Use the modern navigationDestination modifier:

swift
1struct ContentView: View {
2    var body: some View {
3        NavigationStack {
4            List(0..<10) { index in
5                NavigationLink("Item \(index)", value: index)
6            }
7            .navigationDestination(for: Int.self) { id in
8                DetailView(id: id)  // Only created when navigating
9            }
10        }
11    }
12}

NavigationLink(value:) does not take a destination view. It only passes a value. The navigationDestination modifier creates the view lazily when navigation occurs.

Fix 3: Move Expensive Work to onAppear

Keep the init lightweight and defer work to onAppear:

swift
1struct DetailView: View {
2    let id: Int
3    @State private var data: [String] = []
4    @State private var isLoading = true
5
6    // init() is cheap, no network calls
7    var body: some View {
8        Group {
9            if isLoading {
10                ProgressView()
11            } else {
12                List(data, id: \.self) { item in
13                    Text(item)
14                }
15            }
16        }
17        .onAppear {
18            // Only runs when the view actually appears on screen
19            loadData()
20        }
21    }
22
23    private func loadData() {
24        Task {
25            let result = await fetchItems(for: id)
26            data = result
27            isLoading = false
28        }
29    }
30}

onAppear fires only when the view is displayed, not when it is initialized. This is the most important pattern. Even with lazy loading, you should use onAppear for expensive operations.

Fix 4: Use @StateObject for ViewModels

swift
1class DetailViewModel: ObservableObject {
2    let id: Int
3    @Published var items: [String] = []
4
5    init(id: Int) {
6        self.id = id
7        // Do NOT fetch data here. init runs during parent render
8    }
9
10    func loadData() async {
11        items = await fetchItems(for: id)
12    }
13}
14
15struct DetailView: View {
16    @StateObject private var viewModel: DetailViewModel
17
18    init(id: Int) {
19        // _viewModel init is lightweight. Object is created lazily by SwiftUI
20        _viewModel = StateObject(wrappedValue: DetailViewModel(id: id))
21    }
22
23    var body: some View {
24        List(viewModel.items, id: \.self) { item in
25            Text(item)
26        }
27        .task {
28            await viewModel.loadData()
29        }
30    }
31}

.task is like onAppear but automatically cancels when the view disappears, making it ideal for async work.

Only create the link when data is ready:

swift
1struct ContentView: View {
2    @State private var selectedId: Int?
3
4    var body: some View {
5        NavigationView {
6            List(0..<10) { index in
7                Button("Item \(index)") {
8                    selectedId = index
9                }
10            }
11            .background(
12                NavigationLink(
13                    destination: Group {
14                        if let id = selectedId {
15                            DetailView(id: id)
16                        }
17                    },
18                    isActive: Binding(
19                        get: { selectedId != nil },
20                        set: { if !$0 { selectedId = nil } }
21                    ),
22                    label: { EmptyView() }
23                )
24            )
25        }
26    }
27}

This programmatic approach creates the destination only when selectedId is set.

Why Does SwiftUI Do This?

SwiftUI pre-renders destination views for:

  • Transition animations: The destination must be measured and laid out before the push animation starts
  • Prefetching: SwiftUI may cache destination views for faster navigation
  • Declarative model: Views are descriptions of UI, not live objects. SwiftUI evaluates the full view tree to determine what to render

This is different from UIKit where a view controller is pushed and its viewDidLoad runs on demand.

Common Pitfalls

  • Network calls in init: The most common mistake. Move all fetching to onAppear, .task, or a method triggered after navigation. Never make API calls in a view's initializer.
  • Heavy @StateObject initialization: @StateObject creation is deferred by SwiftUI, but if you pass complex parameters to its init, the parameter expressions are still evaluated eagerly. Keep parameter construction cheap.
  • Using NavigationView instead of NavigationStack: NavigationView is deprecated in iOS 16. NavigationStack with navigationDestination(for:) provides lazy destination creation by default.
  • Forgetting LazyView in lists: In a List or ForEach with many items, every NavigationLink destination initializes immediately. Use LazyView for all links in lists.
  • .onAppear firing multiple times: In some SwiftUI versions, onAppear can fire more than once (tab switches, sheet dismissals). Use a flag to prevent duplicate work: if !hasLoaded { loadData(); hasLoaded = true }.

Summary

  • NavigationLink initializes its destination view immediately, not on tap
  • Use LazyView wrapper to defer destination creation (iOS 13+)
  • Use NavigationStack with navigationDestination(for:) for lazy creation (iOS 16+)
  • Move expensive operations (network, database, computation) to onAppear or .task
  • Use @StateObject for view models and trigger data loading in .task, not init()
  • This behavior is by design for SwiftUI's declarative rendering model

Course illustration
Course illustration

All Rights Reserved.