What actually causes a SwiftUI list to redraw more than expected, and how do you fix it?
Unstable Identifiable keys, an over-publishing ObservableObject, and unstable work inside body each widen redraw scope in different ways — and each has a distinct, specific fix.
LEARN BY DOINGPut your intuition to work3 steps
What actually causes a SwiftUI list to redraw more than expected, and how do you fix it?
Use the lesson’s examples and explanation to check your idea.
Personal reflection. Nothing is sent to an AI service.This is one of the better advanced SwiftUI questions because it can't be answered from documentation alone — it requires understanding that body being invoked again is not the same thing as the screen actually redrawing, and that the gap between those two things is entirely a function of identity and equality. SwiftUI decides what changed by diffing, and diffing is only as good as the identity and equality information you give it. Get either wrong and you either redraw far more than necessary or, worse, silently keep stale content on screen.
#Cause 1: unstable row identity
ForEach and List need to know which row is which across renders, and by default that comes from Identifiable or an explicit id: key path. Using an array's indices as identity is the single most common mistake:
struct BadListView: View {let titles: [String]var body: some View {List {ForEach(titles.indices, id: \.self) { index inText(titles[index])}}}}
Insert or remove an item anywhere but the very end, and every index after that point now points at a different string than it did last render — SwiftUI has no way to know "row 3 is the same logical row, its content just changed" versus "everything from row 3 down is a brand-new row," so it treats it as the latter and re-renders (and often animates) the whole tail of the list. The fix is a stable identity that travels with the data, not with its position:
struct Item: Identifiable {let id: UUIDlet title: String}struct GoodListView: View {let items: [Item]var body: some View {List(items) { item inText(item.title)}}}
That only works if the id is actually stable across renders. Generating a fresh identifier inside body defeats the entire point:
// Bad: a new UUID every time body runs — nothing ever looks "the same" to SwiftUI.List(titles.map { Item(id: UUID(), title: $0) }) { item inText(item.title)}
The id needs to come from the model — a server-assigned identifier, a stored UUID created once when the item was created — never manufactured inline during rendering.
#Cause 2: an ObservableObject that over-publishes
ObservableObject's @Published tracking is coarse: it fires objectWillChange when any published property changes, and every view holding that object as @ObservedObject/@StateObject/@EnvironmentObject re-evaluates its body, whether or not that view actually reads the property that changed.
final class ListViewModel: ObservableObject {@Published var items: [Item] = []@Published var selectedID: Item.ID?}struct RowView: View {@ObservedObject var viewModel: ListViewModellet item: Itemvar body: some View {// Only cares about item + selectedID, but re-runs on ANY published// change to viewModel — including unrelated updates to `items`.Text(item.title).background(viewModel.selectedID == item.id ? Color.blue : .clear)}}
Two independent fixes address this. First, stop each row from holding the whole object — pass down only the derived values it needs, and make the row Equatable so SwiftUI can skip re-rendering it when those values didn't actually change:
struct RowView: View, Equatable {let item: Itemlet isSelected: Boolstatic func == (lhs: RowView, rhs: RowView) -> Bool {lhs.item.id == rhs.item.id && lhs.isSelected == rhs.isSelected}var body: some View {Text(item.title).background(isSelected ? Color.blue : .clear)}}// Usage:ForEach(items) { item inRowView(item: item, isSelected: item.id == viewModel.selectedID).equatable()}
.equatable() tells SwiftUI to check your == before re-rendering — if the new value is equal to the old one, it skips diffing that subtree's body entirely, rather than relying only on structural diffing.
Second, on iOS 17+, the @Observable macro replaces ObservableObject's object-level tracking with property-level tracking: a view only re-renders when a property it actually read during the last body evaluation changes, automatically, with no manual Equatable conformance required.
@Observablefinal class ListViewModel {var items: [Item] = []var selectedID: Item.ID?}
A RowView that only reads item and never touches selectedID simply won't be invalidated when selectedID changes — that's a structural fix to the over-publishing problem, not a workaround for it.
#Cause 3: unstable work happening inside body
Recomputing derived values, sorting, or filtering inline inside body means that work reruns on every invalidation regardless of whether its inputs changed, and closures that capture an entire model object (instead of the specific fields they need) can quietly keep a view dependent on more state than it actually uses. Neither of these causes extra work by themselves, but both widen the blast radius of an unrelated change elsewhere in the same object.