How do List, ForEach and the lazy stacks differ, and what do they need from your data?

8.6.1 · L2 · Interview

Predict first

Items are shown with ForEach(items.indices) and the first item is deleted. What does SwiftUI see?

// A: row 0 was removed
// B: the content of every row shifted, because identity is the position
// C: nothing changes
  • A
  • B
  • C

Mental model. ForEach is a stamp that prints a row for each item, and the id is the item’s name tag. If two items share a tag, or the tag changes every time, the stamp cannot tell them apart.

ForEach is not a container: it generates a view per element of a collection and needs an identifier for each, either because the element is Identifiable or through id: \.property. List is a scrolling, row-based container with system styling (separators, swipe actions, selection, editing, sections), backed by UIKit’s collection view, so it reuses rows and scales to large data. ScrollView with LazyVStack or LazyHStack is the flexible alternative: it creates children lazily as they scroll into view but, unlike List, does not reuse and discard them in the same way and gives you full control over appearance. A plain VStack inside a ScrollView builds all its children up front, fine for a handful and a problem for thousands. LazyVGrid and Grid lay out in columns, with Grid aligning cells across rows for small, fixed content.

The identifier is everything. It must be stable and unique: it links a row to its state, its animations and its removal. Using \.self on values that can repeat, or ForEach(array.indices) on an array that changes, makes insertions and deletions look like content changes, produces duplicate-id warnings, and resets row state. Use a database key or a UUID stored on the model. Keep the row view small and the data it reads narrow, so only changed rows update. Put expensive work (sorting, filtering) in the model, not in body. For very large data use pagination or fetch-as-you-scroll with onAppear or task on the last row, or a @Query that fetches lazily.

Identity by index means the id of each remaining row changed, so the framework cannot tell a removal from a content change. Use a stable id.

Common traps

  • Using ForEach(0..<array.count) for dynamic data. Rows are keyed by position, so inserts and deletes misbehave.
  • A VStack in a ScrollView with a large array. It builds every row up front; use a lazy stack or a List.

They might follow up with

  • When do you pick ScrollView + LazyVStack over List? When you need a fully custom look; List gives system behaviour and row reuse.
  • What must a ForEach id be? Stable across updates and unique within the collection.

Related questions

  • 8.1.2 What is structural versus explicit identity in SwiftUI, and what happens to state when identity changes?
  • 8.2.1 How does SwiftUI decide to re-run body, and how do you keep updates cheap?
  • 7.5.2 How does a diffable data source work, and what does it require of your item identifiers?

How does NavigationStack work, and how do NavigationPath and… →