This subchapter is provided as a free sample for The SwiftUI Way book.

You can read this subchapter online or download the sample bundle with PDF and EPUB by clicking the link below.

Download free sample

To get access to the contents of the whole book you need to purchase a copy.

FREE SAMPLE - online version

Maximizing the performance of dynamic lists

SwiftUI has introduced significant under-the-hood optimizations for lists in recent releases to improve scrolling responsiveness and reduce load times. However, the way we construct these containers can directly impact the framework's ability to manage them efficiently. To ensure a smooth user experience, we should design our list content to support fast identification and lazy view creation.

Efficient list performance depends on how quickly SwiftUI can gather identifiers and determine the number of rows to display.

Lists gather all element identifiers eagerly to track data changes and manage view lifetimes. It is critical that accessing these IDs is nearly instantaneous. Using stable, unique identifiers like UUID or integer-based primary keys ensures that SwiftUI can differentiate between elements without performing expensive computations during the identification pass.

struct DayWalksView: View {
    @State private var viewModel = DayWalksViewModel()
    
    var body: some View {
        List {
            ForEach(viewModel.walks) { walk in
                WalkRow(walk: walk)
            }
        }
    }
}

struct Walk: Identifiable {
    var id = UUID()
    var name: String
    var description: String
    var durationInDays: Int
}

The example with DayWalksView follows a standard pattern for efficient dynamic lists. By conforming the Walk model to Identifiable with a stored UUID, we allow SwiftUI to resolve the identity of all items instantly.

While row IDs are gathered upfront, SwiftUI only creates the actual view content for rows on demand, specifically for those in the visible region plus a small buffer. For this lazy creation to work, the content of our ForEach must resolve to a constant number of views.

@Observable class DayWalksViewModel {
    var walks: [Walk] = []
    
    func loadWalks() async {
        let allWalks = await loadAllWalks()
        walks = allWalks.filter { $0.durationInDays == 1 }
    }
    
    private func loadAllWalks() async -> [Walk] {
        // ... load all walks ...
    }
}

struct DayWalksView: View {
    @State private var viewModel = DayWalksViewModel()
    
    var body: some View {
        List {
            ForEach(viewModel.walks) { walk in
                WalkRow(walk: walk)
            }
        }
        .task {
            await viewModel.loadWalks()
        }
    }
}

struct WalkRow: View {
    let walk: Walk
    
    var body: some View {
        VStack(alignment: .leading) {
            // ... row subviews ...
        }
    }
}

Caching the filtering logic within the view model in our example ensures that the ForEach receives a stable, pre-processed collection, moving the computational cost of data preparation out of the identification pass. And since the row is extracted into a dedicated, single view, each element resolves to a constant view count per identifier. This predictability allows SwiftUI to know exactly how many rows to expect, enabling it to defer the creation of the row view bodies until they are required for display.

# ❌ Potentially harmful patterns

If SwiftUI can't determine the exact number of views an element resolves to, it loses its ability to perform lazy loading and may be forced to build all rows in the list upfront.

A common error is placing conditional logic directly inside a ForEach that changes the number of views returned. For example, using an if statement to optionally show a view per row forces SwiftUI to visit and instantiate those rows to determine the final count.

struct DayWalksView: View {
    @State private var allWalks: [Walk] = []
    
    var body: some View {
        List {
            ForEach(allWalks) { walk in
                // ⚠️ Evaluates for every element upfront
                if walk.durationInDays == 1 {
                    VStack(alignment: .leading) {
                        // ... walk row ...
                    }
                }
            }
        }
        .task {
            allWalks = await loadAllWalks()
        }
    }
    
    private func loadAllWalks() async -> [Walk] {
        // ... load all walks ...
    }
}

In this case, SwiftUI will evaluate the ForEach closure for every single element in the collection upfront. This can be particularly harmful for performance if the collection is large and those rows contain intensive memory requirements, such as images. By forcing the system to resolve these views during the identification pass rather than on-demand, we risk saturating the main thread and increasing the memory footprint before a single row is even displayed.

Similarly, wrapping row content in AnyView can be harmful because it hides the type of the content from SwiftUI. Since the underlying structure is hidden, SwiftUI cannot determine the number of rows without evaluating the ForEach closure for every element in the collection upfront. This eliminates the performance benefits of lazy loading, leading to significant overhead and a larger memory footprint as the list grows.

struct DayWalksView: View {
    @State private var viewModel = DayWalksViewModel()
    
    var body: some View {
        List {
            ForEach(viewModel.walks) { walk in
                // ⚠️ Evaluates for every element upfront
                AnyView(
                    // ... row content ...
                )
            }
        }
        .task {
            await viewModel.loadWalks()
        }
    }
}

Efficient list performance is built on predictability. When SwiftUI can calculate the total number of rows and their identifiers without executing every content closure, it can maintain its efficiency even as the dataset grows. By providing stable identifiers and ensuring each element in a ForEach resolves to a constant view count, we can help the system only allocate resources to what is currently visible.