← All topics/UIKit & SwiftUI internals

Question 7

SwiftUI lists, identity, and expensive body work

Scrolling stutters in a SwiftUI List with many rows. What causes unnecessary view invalidation, and how do you structure rows for performance?

Answer outline

Stutter in a long List almost always means SwiftUI is doing more work per scroll than it needs to. Three things fix most of it:

  1. 1.Stable identity: SwiftUI decides what changed by comparing view identity, so random IDs, fresh UUID() values, or array indexes that shift on reorder make old rows look new. SwiftUI rebuilds them, drops their state, and scrolling jumps.
  2. 2.Cheap rows: body can run many times, so don't decode JSON, resize full images, create formatters, or filter large arrays inside it. Prepare data in the model, cache formatters, and downsample images before they reach the row.
  3. 3.Narrow updates: give each row only the small piece of data it needs. If one parent state change invalidates the whole list, split rows into smaller views or pass simpler values so SwiftUI redraws less.

Start side effects from .task or .task(id:), or from an injected model, never from body. SwiftUI cancels .task when the view disappears and restarts .task(id:) when the id changes, which is usually a better fit than ad hoc onAppear network calls.

Principles

  • Identity tells SwiftUI whether this is the same row or a brand new one.
  • body describes the UI, and heavy work belongs in the model before the row is built.
  • Keep invalidation as local as you can, so one state change redraws one row instead of the whole list.
  • .task(id:) gives you cancellation on disappear and a restart when the id changes, which onAppear doesn't.

Use a real domain id that stays the same across reloads and reorders:

Stable identity
ForEach(items) { item in          // Item.id is stable
    RowView(item: item)
}

Follow-up angles

  • ForEach(items.indices) is fragile whenever the list can insert, delete, or reorder, because the index becomes the identity.
  • For images, use AsyncImage or an image pipeline that downsamples, and never decode a full-size image inside a row view.
  • When guessing stops helping, profile with Instruments (the SwiftUI template or Time Profiler) and look for repeated body work and broad invalidations.