Tutorials › Launch Lab Notes › Chapter 6

Who Had to Run Again: Observation, and the Two Things That Quietly Defeat It

LabChapter 6 of the Launch Lab Notes25 minAugust 19, 2026Intermediate

Chapter 5 was about which view SwiftUI thinks it is looking at. This one is about when it bothers to look at all.

Re-running a body is the unit of work in SwiftUI. Everything you care about — smoothness, battery, whether a list scrolls at sixty frames per second — comes down to how many bodies run when something changes. And it is completely invisible. You cannot see a body run, which means you cannot reason about it, which means most people never do.

So this bench makes it visible: six cells, each printing how many times its own body has run.

Six frames as only the fuel property is bumped: in the Observable row the fuel cell's run count climbs while the crew and neither cells stay at one, and in the ObservableObject row all three climb together.

Counting a thing you are not supposed to touch

/// Counts body evaluations for one view identity.
///
/// A reference type, because a body cannot write to its own `@State` — and this
/// has to be written *during* the body evaluation it is counting.
final class BodyCounter {
  private(set) var count = 0
  func bump() { count += 1 }
}

Used like this, at the top of a body:

var body: some View {
  let _ = counter.bump()
  …
}
This is a diagnostic, not a pattern

Writing to anything from inside body is exactly what SwiftUI asks you not to do. It works here only because BodyCounter is a plain class SwiftUI knows nothing about, so incrementing it cannot invalidate anything and cannot loop.

Apple's own version of this is let _ = Self._printChanges(), which logs why a body re-ran. Use that in real code; this bench only draws the number on screen so it can be photographed.

Also: these are body evaluations, not frames or renders. SwiftUI may evaluate a body more than once for a single visible update. The absolute numbers are not the point — the comparison between cells in the same screenshot is.

Two models, one difference

/// The modern one. `@Observable` records which properties each view actually
/// *read* while its body ran, and only invalidates the views that read the
/// property that changed.
@Observable
final class Mission {
  var fuel = 0
  var crew = 0
}
 
/// The one that came before. `objectWillChange` fires for the whole object, so
/// every view holding it is invalidated no matter which property moved — or
/// whether that view reads any property at all.
final class LegacyMission: ObservableObject {
  @Published var fuel = 0
  @Published var crew = 0
}

Both rows get bumped identically. Only fuel ever changes. Here is the start:

The bench at rest: all six cells show one run, fuel and crew both zero.

And here is the same screen after nine fuel bumps:

After nine fuel bumps: in the Observable row the fuel cell shows 10 runs while the crew and neither cells still show 1 run each. In the ObservableObject row all three cells show 10 runs.
reads fuelreads crewreads neither
@Observable10 runs1 run1 run
ObservableObject10 runs10 runs10 runs

Nine changes, and in the modern row two of the three cells never ran again after their first appearance. In the legacy row every cell ran every time — including the one that reads nothing at all.

Observation tracks property reads, and it only watches during body

When SwiftUI evaluates a body, it does so inside an observation scope that records every @Observable property touched. That set becomes the view's dependencies. Touch mission.fuel and you depend on fuel — not on Mission.

The corollary catches people out: a read that happens outside a body registers nothing. Pulling values out in init, or into a stored property, means the view has no dependencies and will not update when they change.

ObservableObject has no such information. @Published fires objectWillChange, every subscriber is invalidated, and the subscription is to the object. Which property moved, and which properties a given view reads, are questions the mechanism cannot ask.

Mini-exercise

Tap CREW +1 instead. In the modern row only the crew cell moves. In the legacy row all three move again. Then tap them alternately and watch the legacy row's counters run at exactly double the rate of the modern row's — a real, measurable cost for the same user-visible behaviour.

The two mistakes that erased the difference

The first build of this bench produced a much less interesting screenshot. The legacy row behaved as expected, but in the @Observable row the crew and "reads neither" cells were also climbing — about half as fast, but climbing. Two mistakes, both mine, both worth more than the demo itself.

@StateObject in the parent subscribes the parent

@StateObject private var legacy = LegacyMission()   // ← the whole screen re-runs
@State       private var legacy = LegacyMission()   // ← just holds it

@StateObject is not storage — it is storage plus a subscription. The parent held the legacy model that way, so every legacy bump invalidated the parent, which rebuilt the entire screen, including all six cells.

@State holding a class instance keeps it just as stably and subscribes to nothing. Ownership and observation are separate decisions, and the property wrappers make them look like one.

The general shape of this bug

A view that owns a model and passes it down will re-render on every change to it — so everything below it is at the mercy of whatever the parent diffing can prove. Push the subscription as far down the tree as you can: let the leaf that reads fuel be the thing that observes fuel. That is the entire performance argument for @Observable, and it only pays off if you do not accidentally subscribe higher up.

A closure in a view defeats diffing

This one is subtler and cost me longer. The cells originally took a closure:

ModernCell(label: "reads fuel", tint: Lab.mint) { $0.fuel }

Elegant, and fatal. When a parent re-runs, SwiftUI compares the new child struct against the old one to decide whether the child's body needs to run. A closure can never be proven equal. So a view holding one is re-run every single time its parent is, no matter how precise the observation underneath it is.

The fix was to store something comparable:

/// What a cell reads. Deliberately an enum rather than a closure: SwiftUI
/// decides whether to skip a child by comparing its stored properties, and a
/// closure can never be proven equal — so a cell carrying one re-runs whenever
/// its parent does, no matter how precise the observation underneath is.
enum Reads {
  case fuel, crew, neither
}

With both fixed, the crew and "reads neither" cells dropped from ten runs to one.

Closures in views are extremely common

Every Button(action:), every onTap handler passed down as a parameter, every "configure this row" closure. They are not wrong — most are cheap and unavoidable. But a view that stores a closure has opted out of diffing, so it belongs on cheap leaf views and not wrapped around an expensive subtree.

If a subtree is expensive and its inputs really are unchanged, EquatableView — or conforming the view to Equatable and writing == yourself — lets you assert what SwiftUI cannot infer.

Which wrapper, and why

One source of truth, borrowed downward

Every one of these answers the same question: who owns this? Exactly one place should. Everything else borrows a reference to it — @Binding for values, the observed object for models. Two copies of a value that are supposed to agree is not a data-flow pattern; it is a bug you have not hit yet.

Challenges

  1. Add a seventh cell that reads mission.fuel in its init and stores it in a plain property. Bump fuel. The counter stays at one and the number on screen never updates — a dependency you did not register looks exactly like a view that is working fine until it is not.

  2. Put the closure back on one modern cell only, and leave the enum on its neighbour. One climbs, one does not, side by side, with everything else identical.

  3. Measure a real list. Drop let _ = Self._printChanges() into a row of a list in a project you already have, scroll, and read the console. The output names what changed, which is usually more surprising than how often.

  4. Make the "neither" cell honest. Have it read mission.fuel but discard the result. It re-runs — proving the dependency comes from the read, not from displaying anything.

  5. Convert one legacy model. Take an ObservableObject from your own code, make it @Observable, delete every @Published, and change @StateObject to @State and @ObservedObject to a plain let. Count the diff. It is usually smaller than expected and removes more code than it adds.

Key Points

Six chapters, and the Lab has now covered how motion works, what it is built on, and what makes it run. The next bench turns outward to something with no SwiftUI in it at all: the layout system underneath everything, and the negotiation between a parent that offers space and a child that decides what to do with it.

SwiftUI
SwiftUI tutorials for building native app screens, layouts, navigation, and state-driven interfaces.
Swift
Swift fundamentals for app developers who want to understand the language behind real iOS and macOS apps.
Ship iOS
Shipping workflows for iOS apps.
📚 Go deeper with LIPAI WANG’s hands-on Udemy bootcampsBrowse all courses →
← Ch 5: Which View Is WhichCh 7: The Parent Proposes→
SwiftUIUltimate SwiftUI SeriesSwiftUI tutorials for building native app screens, layouts, navigation, and state-driven interfaces.SwiftUltimate Swift SeriesSwift fundamentals for app developers who want to understand the language behind real iOS and macOS apps.Ship iOSShip iOS Apps SeriesShipping workflows for iOS apps: signing, TestFlight, App Store Connect, CI, and release hygiene.

Ship your apps faster

When you're ready to publish your Swift app to the App Store, Simple App Shipper handles metadata, screenshots, TestFlight, and submissions — all in one place.

Try Simple App Shipper
5 free articles remainingSubscribe for unlimited access