SwiftUI List re-renders killing performance? Here's the fix
Your SwiftUI List lags with big datasets because every row re-renders on any state change. I'll show you the three real causes and how to kill them.
You've got a SwiftUI List with hundreds of rows, and scrolling feels like wading through mud. Every tap on one cell causes all rows to flash or redraw. I've debugged this exact mess on apps with 10,000+ records. The culprit is almost always one of three things: missing or wrong identity, lack of Equatable conformance, or shoving StateObject where it doesn't belong.
Let's fix each one, starting with the biggest offender.
1. Bad or missing row identity — the silent killer
SwiftUI diffs lists by identity. If you give it id: \.self on a string or a simple struct, it'll treat every row as new during any state update. That triggers a full re-render cycle for every visible row, even if nothing changed.
Real-world trigger: You have a List showing user profiles. You tap a "like" button on one profile, and the entire list flickers.
The fix is stable identity — an id that doesn't change across updates. Use a UUID or a database primary key.
struct User: Identifiable {
let id: UUID
let name: String
var isLiked: Bool
}
// In your view
List(users) { user in
UserRow(user: user)
}
If you're using ForEach with a range-based index (like ForEach(0..<items.count)), stop. That's asking for trouble — indexes shift when data changes. Always use ForEach(items) with identifiable data.
One more thing: don't use id: \.objectID on Core Data objects unless you're absolutely sure they're unique. I've seen duplicate IDs from relationships cause re-renders that looked like identity was fine. Use the entity's own unique attribute or a computed hash.
2. Models aren't Equatable — SwiftUI can't tell what changed
Even with perfect identity, SwiftUI still re-renders rows if it can't prove they're unchanged. By default, structs don't have Equatable synthesis for all properties. SwiftUI uses the EquatableView wrapper or a custom == to skip redraws. If you skip this, every row re-evaluates its body on any state change anywhere in the list.
Real-world trigger: You've got a list of product items with prices and availability. A background refresh updates one item's stock. Without Equatable, every row re-renders.
Make your model conform to Equatable. If you have computed properties that don't affect the UI, override == to only compare the displayed fields.
struct Product: Identifiable, Equatable {
let id: Int
let name: String
var price: Decimal
var inStock: Bool
static func == (lhs: Product, rhs: Product) -> Bool {
lhs.id == rhs.id &&
lhs.price == rhs.price &&
lhs.inStock == rhs.inStock
// Don't compare 'internalDiscount' — it's not shown
}
}
Then wrap your row in EquatableView or use the .equatable() modifier:
List(products) { product in
EquatableView(content: ProductRow(product: product))
}
// Or shorter:
List(products) { product in
ProductRow(product: product)
.equatable()
}
If your row view uses @ObservedObject or @StateObject, .equatable() won't help. That's your next problem.
3. StateObject in the row — it's a trap
This one catches everyone. If each row creates its own @StateObject (e.g., a view model with API calls), SwiftUI destroys and recreates that object when the row's identity changes. Even if identity is stable, the row's @StateObject triggers a body re-evaluation on any state change inside it — which can cascade.
Real-world trigger: You have a chat message list. Each message row has a MessageViewModel that loads an avatar. Tapping one row expands it, and every other row also redraws.
Don't use @StateObject in a list row. The row is already re-created when its identity changes. Use @ObservedObject and pass the model from the parent. Or better, keep the row stateless — push data down, never let the row own lifecycle-heavy objects.
// Bad — each row creates its own view model
struct MessageRow: View {
@StateObject private var viewModel = MessageViewModel()
let message: Message
var body: some View { ... }
}
// Good — parent manages the view model
struct MessageRow: View {
@ObservedObject var viewModel: MessageViewModel
var body: some View { ... }
}
// Even better — no view model, just async image loading in parent
struct MessageRow: View {
let message: Message
let avatar: UIImage?
var body: some View { ... }
}
If you absolutely must have per-row state (like an expanded/collapsed toggle), use @State on a simple Bool. That's cheap and won't cascade. @State is fine — it's @StateObject with its own Combine pipeline that kills performance.
Quick reference summary
| Cause | Symptom | Fix |
|---|---|---|
Bad identity (\.self or index) |
All rows re-render on any change | Use Identifiable with a stable UUID or PK |
| Non-Equatable models | Rows re-render even when data hasn't changed | Add Equatable conformance, use .equatable() |
@StateObject in row |
Slow rendering, especially with many visible rows | Use @ObservedObject or keep rows stateless |
One last thing — if you're still seeing issues after applying all three, check your List for complex HStack/VStack nesting or custom drawing. Sometimes the problem isn't re-rendering — it's layout computation. Profile with Instruments (SwiftUI template) to confirm. But I'm betting one of these three is your problem. Fix them in order, and you'll kill the lag.
Was this solution helpful?