Trace one view through its first render and a value change: what is logged, in what order, and what about an equal write?
Predict first
In what order are these logged?
@Observable final class Driver { var v = 0 }
let driver = Driver()
struct V: View {
var body: some View {
let _ = note("body(\(driver.v))")
Text("x")
.onAppear { note("onAppear") }
.task { note("task start") }
.onChange(of: driver.v) { old, new in note("onChange \(old)->\(new)") }
}
}
_ = show(V()); flush()
driver.v = 1; spin(); flush()
driver.v = 1; spin(); flush()
- body(0) | onAppear | task start / body(1) | onChange 0->1 / nothing for the equal write
- onAppear | body(0) | task start
- onChange 0->0 on first render
Mental model. body is drawing the page, onAppear is the page being shown, task is a runner sent off once it is shown, and onChange is the doorbell that rings after the page was redrawn for a new value.
The check logs a view with all four. On first render the order is body, then onAppear, then the task body starts: the body must produce the description before anything can appear, and the task is launched once the view is on screen. onChange(of:) did not fire at the first render (the iOS 17 form takes initial: true if you want it to). When the observed value changes, the order is again body first (which sees the new value) and then the onChange action with the old and new values. Setting the property to the value it already has produced no further body and no onChange here.
Use that order when designing effects. onAppear may run repeatedly (tab switches, navigation back) and is synchronous; .task starts async work, is cancelled when the view goes away, and .task(id:) restarts it when the id changes; onChange is for reacting with a side effect after a value changed, not for deriving state (compute that in body). Do not assume the effect has run when body returns, and do not read “has appeared” into init. Timing between the task start and the first frame is not guaranteed beyond what I observed, so write effects that tolerate either order.
Body first, effects after, no onChange for the first value or an equal write.
Common traps
- Expecting
onChangeto run for the first value. Useinitial: true. - Deriving state inside
onChange. It costs an extra render; compute it inbody.
They might follow up with
- How do you run work once per id?
.task(id:). - Is
onAppearonce-only? No.
Related questions
- 8.4.1 When do onAppear, task and onChange run, and which one do you use for async work?
- 8.2.1 How does SwiftUI decide to re-run body, and how do you keep updates cheap?
- 8.10.1 A view’s init runs on every parent update. When does the initial value of @State or @StateObject actually get…
Which actor do a view’s body, its task closures and its models run on… →