Your SwiftUI screen redraws constantly and scrolling stutters - how would you find out why, and what state-management mistake usually causes it?
A re-render storm usually comes from one large ObservableObject whose @Published properties fan out too widely, so any single field change invalidates every view reading the object rather than just the row that changed. Use this swiftui answer to show the decision, trade-off, and evidence rather than a memorised definition. It also connects state management to the point an interviewer is testing.
What the interviewer is scoring
- Does the candidate distinguish view identity from view state, since SwiftUI diffs by identity first
- Can they explain why @Published on a coarse-grained object invalidates every subscriber, not just the one that reads the changed field
- Do they reach for Instruments or the SwiftUI view-body print rather than guessing at the cause
- Whether they know how the Observable macro in Swift 5.9 changes the granularity of invalidation compared to ObservableObject
- Can they explain the cost of computed properties inside a view body being re-evaluated on every redraw
Answer
Short answer
A re-render storm usually comes from one large ObservableObject whose @Published properties fan out too widely, so any single field change invalidates every view reading the object rather than just the row that changed.
What SwiftUI actually invalidates on a change
SwiftUI does not re-render "the screen" when state changes. It re-evaluates the body of every view that reads a piece of state that changed, then diffs the resulting view tree against the previous one and only touches the parts that differ. The redraw storm candidates describe is almost never SwiftUI doing unnecessary drawing; it is the dependency graph being wider than it needs to be, so far more body evaluations run than the visible change justifies.
The classic cause is a single ObservableObject holding everything a screen needs: the list of items, the selected filter, a loading flag, an error message. Every view that holds that object via @ObservedObject or @EnvironmentObject subscribes to the object as a whole through objectWillChange. Mutating any one @Published property fires objectWillChange for the entire object, so every subscribing view re-evaluates its body, even the ones that only read a property nothing to do with the one that changed. In a list screen, that means selecting a filter re-evaluates every row's view model reference, not just the filter control.
Diagnosing it rather than guessing
Before proposing a fix, find where the extra work is actually happening. Two cheap tools do most of the work: a print statement or self._printChanges() inside the body of the suspect view tells you exactly which views are re-evaluating and how often, and Instruments' SwiftUI template shows body-evaluation counts against a timeline you can correlate with the scroll or the interaction that triggered the stutter. Guessing at a fix without this step usually produces a change that helps the wrong view.
struct RowView: View {
let item: Item
var body: some View {
// Cheap way to see which rows are re-evaluating and why.
Self._printChanges()
return Text(item.title)
}
}
Once you can see that every row prints on a change that only affects one row, the diagnosis is confirmed: the dependency graph is too coarse.
The fix: narrow what each view depends on
The fix is to shrink the unit of observation to match the unit of change. Split the one large object into smaller ones scoped to what a given view actually needs, so a row observes only its own item rather than the parent list's object. Where a value is derived and does not need to trigger a diff of its own, keep it as a plain computed property rather than a published one, since publishing something that only exists to be read once per redraw adds an invalidation with no benefit.
Swift 5.9's @Observable macro changes the mechanics in your favour here. Instead of one objectWillChange signal per object, it tracks property access per view at the field level, so a view reading only model.title is invalidated when title changes and not when subtitle changes on the same instance. That does not remove the need to think about scope, but it means the coarse-grained object is no longer paying for every field with every subscriber; you get some of the win for free, and the remaining discipline is not stuffing unrelated concerns into one model just because it is convenient to inject.
// Before: one big object, any @Published field invalidates every subscriber.
final class ScreenModel: ObservableObject {
@Published var items: [Item] = []
@Published var filter: Filter = .all
@Published var isLoading = false
}
// After (Swift 5.9+): @Observable tracks field-level reads per view,
// so a row view reading only `item` is untouched by a filter change.
@Observable
final class ScreenModel {
var items: [Item] = []
var filter: Filter = .all
var isLoading = false
}
Where a List makes it worse on its own
A second, independent contributor is the identifier SwiftUI uses to diff a List or ForEach. If rows are identified by array index rather than a stable id, inserting or removing an item makes SwiftUI believe every row after the change point is a different view with new content, rather than the same view that moved. That forces a full re-evaluation of the tail of the list on every mutation, on top of whatever the state-management issue is doing. Using Identifiable with a stable, content-independent id fixes this layer separately from the observation fix above, and both usually need fixing together before the stutter fully disappears.
The trap in this question is treating "reduce state" and "fix identity" as the same fix. They address different mechanisms: one controls how many views re-evaluate their body, the other controls how expensive the diff of the resulting tree is. A candidate who only narrows the observable object but leaves index-based ids in a mutating list will still see unnecessary work, and will misdiagnose the remaining stutter as a failed fix rather than a second cause.
Measure which views are re-evaluating before changing anything - the fix for a re-render storm depends entirely on whether the cause is observation scope, identity, or both.
© 2026 Preptima. Originally published at preptima.com.
Likely follow-ups
- How would you split one large view model into several without duplicating fetch logic?
- What does the Observable macro track differently from @Published, and why does that matter for a list of a thousand rows?
- Why can a List with a poor id cause both wasted diffing and, separately, stale row content?
- What would you check first in Instruments to confirm the redraw is coming from state rather than layout?
Related questions
- Your Node service has low CPU but latency spikes for every request at the same moment. What is happening?mediumAlso on performance4 min
- A read-only endpoint that returns fifty thousand rows is slow and memory-heavy in EF Core. What is the context doing?mediumAlso on performance4 min
- How do you choose which devices to test on, and what will an emulator never tell you?mediumAlso on ios5 min
- A Compose screen with a long list is janky while scrolling - walk through how you'd find and fix the recomposition problem causing it.mediumAlso on performance5 min