List and ForEach
List is SwiftUI's container for displaying scrollable, platform-native collections of rows—it handles scrolling, row separators, section headers, swipe actions, and selection for you, mirroring UITableView/UICollectionView behavior on iOS. ForEach is a more general-purpose construct: it generates a view for each element of any RandomAccessCollection, and can be used inside List, inside a VStack, or anywhere else multiple views need to be produced from a collection. The two are frequently combined—List provides the scrollable chrome, ForEach supplies the repeated content—but they solve different problems and neither requires the other.
Cricket analogy: Just as a stadium's scoreboard handles the overall display chrome — scrolling overs, section breaks between innings — while the ball-by-ball commentary feed generates one entry per delivery, List provides scrollable chrome and ForEach supplies repeated content, and neither requires the other.
Identity: The Core Requirement
SwiftUI needs a stable identity for every row so it can efficiently diff which elements were added, removed, or reordered between renders, rather than redrawing everything. ForEach expresses this either by requiring the data's elements to conform to Identifiable (with a stable id property) or by supplying an explicit key path via ForEach(data, id: \.someProperty). Using an unstable identity—like array index for a mutable array—causes SwiftUI to misattribute state (e.g. a text field's content 'jumping' to the wrong row) when items are inserted, removed, or reordered.
Cricket analogy: Just as mixing up two players both wearing jersey number 7 on different teams would misattribute a wicket to the wrong batsman, using array index instead of a stable id in ForEach can make SwiftUI misattribute a text field's content to the wrong row when the list reorders.
struct Task: Identifiable {
let id = UUID()
var title: String
var isDone: Bool
}
struct TaskListView: View {
@State private var tasks: [Task] = [
Task(title: "Write proposal", isDone: false),
Task(title: "Review PR", isDone: true)
]
var body: some View {
List {
ForEach(tasks) { task in
HStack {
Text(task.title)
Spacer()
if task.isDone {
Image(systemName: "checkmark.circle.fill")
.foregroundStyle(.green)
}
}
}
.onDelete { indexSet in
tasks.remove(atOffsets: indexSet)
}
}
}
}Sections, Styles, and Row Modifiers
List supports Section for grouping rows under headers and footers, and a listStyle(_:) modifier (.plain, .grouped, .insetGrouped, .sidebar) to match different platform conventions. Row-level interactivity comes from modifiers like .swipeActions, .onDelete, and .onMove (the latter two typically paired with an .editMode / EditButton toggle), letting you build fully native list-editing experiences with only a few lines of declarative code instead of manually managing UITableViewDataSource/UITableViewDelegate as in UIKit.
Cricket analogy: Just as a scorecard groups deliveries under over-headers with a format switch between Test and T20 display, swiping a fielder's card to substitute them replaces the manual bookkeeping a scorer once tracked by hand, mirroring Section, listStyle, and swipeActions replacing UITableViewDataSource code.
List {
Section("Pending") {
ForEach(tasks.filter { !$0.isDone }) { task in
Text(task.title)
}
}
Section("Completed") {
ForEach(tasks.filter { $0.isDone }) { task in
Text(task.title)
.strikethrough()
}
}
}
.listStyle(.insetGrouped)ForEach isn't just for List—ForEach(1...3) { number in Text("Item \(number)") } works just as well inside a plain VStack or HStack. List is a scrollable, styled container; ForEach is purely a view-generating loop construct that can live inside List, a Stack, a Grid, or elsewhere.
Using ForEach(0..<tasks.count, id: \.self) { index in ... } and indexing into the array inside the closure is a common anti-pattern: it re-introduces the index-as-identity problem List/ForEach is designed to avoid, and it makes deletions/insertions error-prone since the closure captures an index, not a stable identity.
- List provides scrolling, native row styling, sections, and platform-consistent list chrome.
- ForEach is a general view-generating loop over a collection, usable inside or outside List.
- Stable identity (via Identifiable or an explicit id key path) lets SwiftUI correctly diff insertions, deletions, and reorders.
- Avoid using raw array indices as ForEach identity for mutable collections—it causes state to attach to the wrong row.
- Section groups rows with headers/footers; listStyle(_:) adapts List's appearance to platform conventions.
- .onDelete and .onMove attach native swipe-to-delete and drag-to-reorder behavior to a ForEach inside a List.
Practice what you learned
1. What is the primary responsibility of List that ForEach does not provide on its own?
2. What does ForEach need in order to correctly diff a collection across updates?
3. Why is `ForEach(0..<tasks.count, id: \.self)` combined with array indexing considered an anti-pattern for a mutable array?
4. What does listStyle(.insetGrouped) control?
5. Which modifier, attached to a ForEach inside a List, enables native swipe-to-delete?
Was this page helpful?
You May Also Like
LazyVStack and LazyHStack
Understand how LazyVStack and LazyHStack defer creation of off-screen views inside a ScrollView, enabling efficient custom scrolling layouts.
List Performance in SwiftUI
Learn how SwiftUI's List actually renders and recycles rows, and the concrete techniques for keeping scrolling smooth in large or complex data sets.
@State and @Binding
Understand how @State owns local view state and how @Binding lets child views read and write that state without owning it themselves.
NavigationStack Basics
Understand how NavigationStack replaced NavigationView, how navigationDestination drives push navigation, and how to structure hierarchical screens in SwiftUI.