← Back to list

What Migrating Off @ObservableObject Taught Me About SwiftUI Re-Renders

Fine-grained observation, and the re-render problem nobody warns you about

Oniel Rosario · 2026-06-23 19:52 · 0 claps · 4.7 min read
#ios-development #ios #mobile-app-development #swiftui #ios-app-development
Open on Medium ↗
Wiki topics: 📱 · Mobile Development

What Migrating Off @ObservableObject Taught Me About SwiftUI Re-Renders

Fine-grained observation, and the re-render problem nobody warns you about

I thought I’d already solved my app’s rendering problem.

In an earlier pass, I’d traced the app’s energy and thermal issues back to the UI layer, DisplayList cycles, Core Animation compositing, and blur and shadow rendering on continuously updated views. I fixed that. CPU stabilized, energy impact dropped, and thermal pressure went away.

So when the player screen still felt slightly heavier than it should, I assumed I was chasing diminishing returns.

I was looking at a different bottleneck entirely.

The symptom: views were re-rendering for state they never read

Noema’s player screen pulls from a single view model:

Track metadata, playback progress, generation status, and queue state.

Reasonable architecture on paper, one source of truth, multiple views observing it.

The problem showed up when I profiled the app once again out of curiosity.

A view that displayed only the track title re-executed its body every time the playback progress ticked. A view that only rendered a static album artwork was re-evaluated on every queue update. None of these views read the property that changed. They re-rendered anyway.

Why is a view re-rendering for data it never touches?

That question led somewhere more fundamental than a profiling pass; it led to how ObservableObject actually decides when to notify.

The root cause: ObservableObject doesn’t know what your view reads

ObservableObject and @Published were never property-aware. They're object-aware.

When any @Published property on the object changes, objectWillChange fires. Every view holding an @ObservedObject or @StateObject reference to that instance gets notified, regardless of which property it actually reads in its body.

class PlayerViewModel: ObservableObject {
    @Published var trackTitle: String = ""
    @Published var progress: Double = 0
    @Published var queueCount: Int = 0
}

Update progress at 1 Hz during playback, and every view observing PlayerViewModel re-evaluates its body 1 Hz, including the title label and the artwork view, which have nothing to do with progress.

Individually, that’s cheap. At 1 Hz, across a hierarchy of subviews, it’s a tax you pay continuously for the lifetime of every session.

That’s the same category of problem I’d already fixed at the rendering layer. I just hadn’t fixed it at the state layer.

The fix: @Observable and property-level tracking

Swift’s Observation framework, introduced with the @Observable macro, tracks dependencies at the property level instead of the object level.

SwiftUI records exactly which properties a view body reads, and only invalidates that view when one of those properties changes, not whenever anything on the object changes.

@Observable
class PlayerViewModel {
    var trackTitle: String = ""
    var progress: Double = 0
    var queueCount: Int = 0
}

No @Published. No objectWillChange. The macro generates the tracking machinery at compile time, and ownership moves from @StateObject / @ObservedObject to plain @State:

struct PlayerView: View {
    @State private var viewModel = PlayerViewModel()
    var body: some View {
        VStack {
            TitleLabel(title: viewModel.trackTitle)// re-renders only on title change
            ProgressBar(value: viewModel.progress) // re-renders only on progress change
            ArtworkView() // never re-renders for either
        }
    }
}

Same view model, same shared state, but now each subview’s re-render is scoped to the property it actually reads.

The title label stops re-evaluating every time progress ticks.

The artwork view stops re-evaluating for queue changes it never looks at.

The migration isn’t free; there’s one gotcha that will bite you

The macro is marketed as close to a drop-in replacement. It isn’t, and the gap is in initialization timing, not syntax.

@StateObject's initializer takes an autoclosure and guarantees the wrapped object is created exactly once, no matter how many times the parent view's body re-executes. @State doesn't guarantee the value it stores. If the closure that produces an @Observable instance gets re-evaluated, the type's initializer runs again.

In practice, any @Observable model doing setup work in init:

  • registering for notifications
  • kicking off a polling loop
  • opening a session

Needs to be defensive about being initialized more than once, or initialized at a layer of the hierarchy where that's actually safe.

I had one place in Noema's session-state model where this mattered and would have gone unnoticed without the migration's own test pass:

A stale listener registered itself a second time, silently, with no crash to point at it.

The lesson is that the WWDC framing of @Observable as a near drop-in replacement is true for the observation mechanism but not quite true for the object lifecycle.

Audit any init that does side-effecting work before you touch the property wrapper.

What changed after the migration

Across Noema’s player, history, and generation-status screens:

  • Body re-execution counts for static subviews (artwork, titles, badges) dropped to effectively zero during active playback
  • The 1 Hz progress tick no longer cascaded into sibling views that didn’t read progress
  • @Bindable replaced @ObservedObject for the handful of views that needed two-way bindings, with no behavior change
  • Boilerplate dropped, no more remembering to mark every property @Published, no more silent bugs from forgetting to

None of this shows up as a dramatic CPU number the way the rendering layer fix did. It shows up as fewer unnecessary DisplayList.ViewUpdater cycles feeding into the same compositing pipeline I'd already tuned, which means the rendering fix and the observation fix were solving the same problem from two different layers of the stack.

Why this matters specifically for AI-driven, real-time apps

Most SwiftUI performance advice assumes infrequent state changes:

Button taps, Network responses, and screen transitions.

AI apps built around continuous streams don’t get that luxury:

Playback progress, generation status, and SSE-driven updates are all changing on their own clock while the user is looking at the screen.

In that environment, observation is a multiplier.

Every property change on a shared view model re-renders every view that watches it for the entire duration of the session. Observation is what makes the shared state actually viable in a continuously-updating system.

Lessons Learned

  • ObservableObject invalidates at the object level; @Observable invalidates at the property level, that distinction compounds under continuous updates
  • A view re-rendering for a state that it never reads is a real cost, even when each render is individually cheap
  • @Observable migration is mostly mechanical, except for init side effects (audit those before you migrate)
  • @Bindable is the direct replacement for two-way bindings that used to go through @ObservedObject
  • Re-render reduction at the state layer and rendering-cost reduction at the view layer feed the same pipeline

Closing Thoughts

Fixing the rendering layer made Noema’s UI cheaper to draw. Fixing the observation layer made it cheaper to decide what needed drawing in the first place. Both mattered, and neither would have shown up clearly without actually questioning why a view I assumed was static kept re-executing its body.

If you’re holding shared state in ObservableObject and your views are doing more work than they should, the property they're re-rendering for might not even be one they read.


메타데이터
post_id
ec7236a5e64b
slug
what-migrating-off-observableobject-taught-me-about-swiftui-re-renders-ec7236a5e64b
url
https://medium.com/@orose2689/what-migrating-off-observableobject-taught-me-about-swiftui-re-renders-ec7236a5e64b
canonical_url
https://medium.com/@orose2689/what-migrating-off-observableobject-taught-me-about-swiftui-re-renders-ec7236a5e64b
author_url
https://medium.com/@orose2689
status
ok
fetched_at
2026-06-24 11:06:28