What makes a SwiftUI body run again, how do you see it, and how do you reduce unnecessary invalidation?
Predict first
A parent view re-evaluates after a state change. How many times does a child run its body when its input is the same, and when its input changes?
import SwiftUI
import UIKit
nonisolated(unsafe) var bodies = 0
@MainActor func host<V: View>(_ v: V) -> UIHostingController<V> {
let h = UIHostingController(rootView: v)
let w = UIWindow(frame: CGRect(x: 0, y: 0, width: 390, height: 800))
w.rootViewController = h
w.makeKeyAndVisible()
h.view.layoutIfNeeded()
RunLoop.main.run(until: Date().addingTimeInterval(0.3))
return h
}
@MainActor @Observable final class Model { var tick = 0 }
struct Child: View {
let value: Int
var body: some View { bodies += 1; return Text("\(value)") }
}
struct Fixed: View {
var model: Model
var body: some View { VStack { Text("\(model.tick)"); Child(value: 7) } }
}
struct Moving: View {
var model: Model
var body: some View { VStack { Text("\(model.tick)"); Child(value: model.tick) } }
}
@MainActor func run() {
for which in 0..<2 {
bodies = 0
let m = Model()
let h = which == 0 ? host(Fixed(model: m)) as UIViewController : host(Moving(model: m))
let first = bodies
m.tick += 1
RunLoop.main.run(until: Date().addingTimeInterval(0.3))
h.view.layoutIfNeeded()
print(which == 0 ? "same input" : "changed input", first, bodies)
}
}
MainActor.assumeIsolated { run() }
- same input 1 1, then changed input 1 2
- same input 1 2, then changed input 1 2
- same input 1 1, then changed input 1 1
Mental model. A spreadsheet recalculates only the cells that depend on the one you edited. Make each cell depend on as few others as possible and the sheet stays instant.
SwiftUI re-evaluates a view’s body when something that body depends on changes: @State or @Binding values, an @Environment value it reads, an observed model, or its own input values being different from last time. With the Observation framework (@Observable) the dependency is recorded per property that the body actually reads, so a view that reads only model.tick is not invalidated when another property changes (Q264 covers the mechanism); with ObservableObject any @Published change notifies every observer. A child view whose stored inputs are unchanged is not re-evaluated when its parent re-runs, which is why passing plain values down is cheap, as the first check shows. A body that is cheap (no formatting, sorting or allocation inside it) matters more than skipping it, because bodies run often.
To see what is happening, call Self._printChanges() inside a body in a Debug build to print which property triggered it, and use the SwiftUI instrument (and Time Profiler) to count and time body updates. To reduce them: keep state as local as possible (put a frequently changing value in a small subview rather than the whole screen), pass the child only the values it needs rather than the whole model, move expensive derived values out of body into the model or a cache, give list items stable identity (Q260) so they are updated rather than rebuilt, and use EquatableView or .equatable() when a view’s inputs are not automatically comparable. Never mutate state during body evaluation, which creates a feedback loop.
A child whose inputs are unchanged is skipped; a child given the new value runs again. That is why narrow inputs keep invalidation cheap.
Common traps
- Passing the whole model to every child. The child then depends on every property it reads, and more of it re-runs.
- Heavy work in
body. Sorting, formatting or building formatters on every evaluation turns each invalidation into a hitch.
They might follow up with
- How do you find the cause of a re-render?
Self._printChanges()in Debug, and the SwiftUI instrument for counts and durations. - Why did an
ObservableObjectscreen re-render more than the@Observableone? The first notifies on any published change; the second tracks the properties read.
Related questions
- 21.4.2 What is the difference between VStack in a ScrollView, LazyVStack and List for long collections, and what are…
- 21.2.1 Which Instruments templates do you reach for, how do you read a Time Profiler trace, and where can a sampling…
- 8.3.3 How does the Observation framework differ from ObservableObject, and how does tracking work?
- 8.1.2 What is structural versus explicit identity in SwiftUI, and what happens to state when identity changes?
What is the difference between VStack in a ScrollView, LazyVStack and… →