How we built ObservedOrStateObject to let SwiftUI views own or observe view models without duplicating code.

This is part one of the series. This post kicks off a series of SwiftUI puzzles we had to solve while shipping the app in production. We’re walking through the issues we hit; dissecting them is one of the fastest ways to level up with advanced SwiftUI.

ObservedOrStateObject: Bridging @StateObject and @ObservedObject in SwiftUI

This post kicks off a series of SwiftUI puzzles we had to solve while shipping the app in production. We’re walking through the issues we hit; dissecting them is one of the fastest ways to level up with advanced SwiftUI.

We built ObservedOrStateObject while writing unit tests: the views needed to spin up their own view models in production, yet tests and previews had to inject mocked instances without rewriting the view. The wrapper emerged to unify those scenarios.

An ObservedOrStateObject wraps either a newly created or externally provided ObservableObject, letting SwiftUI treat it like a regular dynamic property.

Thanks to ObservedOrStateObject, our views can work in two modes:

  • Own the view model (like StateObject) when the view builds it internally.
  • Observe an injected instance (like ObservedObject) when tests, previews, or coordinators supply the view model.

Without a bridging wrapper we would duplicate view code or risk double-instantiating models. The custom property wrapper lets the view declare a single property:

@ObservedOrStateObject private var viewModel: AddProductViewModel

and decide during initialization whether to pass a builder closure (for owned state) or an already-constructed view model.

How it works

The wrapper owns a StorageBox that is stored with @StateObject, so the box keeps a stable identity across view updates. The box is initialized with the model, either by executing the autoclosure in init(wrappedValue:) or by accepting an existing instance via init(observed:).

StorageBox itself conforms to ObservableObject and holds the real model plus a cached ObservedObject projection. Each time SwiftUI evaluates the view body, the box returns the same projected wrapper, preserving SwiftUI’s dynamic-property identity. When the underlying model is swapped, ensureSubscriptionIsCurrent() refreshes the subscription so the wrapper keeps relaying changes.

The let objectWillChange = ObservableObjectPublisher() line gives the box its own publisher. The box subscribes to the underlying model’s objectWillChange, and when the model emits, the box forwards that event by calling objectWillChange.send(). SwiftUI listens to the box’s publisher, so relaying the events is essential for the view to refresh; without the local ObservableObjectPublisher, SwiftUI would never be notified to redraw.

What happened?

An early implementation of ObservedOrStateObject recreated the ObservedObject wrapper on every access of $viewModel, violating SwiftUI’s identity contract. Here’s what went wrong:

  • The wrapper needed to bridge @StateObject (view-owned instances) and @ObservedObject (externally supplied instances) without duplicating code.
  • The original implementation recreated the ObservedObject wrapper on every access of $viewModel.
  • SwiftUI expects dynamic property wrappers to maintain identity across updates. Because ours did not, published changes stopped propagating after the first body pass, leaving views stuck with stale data.
  • The fix caches the ObservedObject wrapper inside the box and keeps the Combine subscription in sync with the current object identity.

Background

We introduced ObservedOrStateObject to simplify views such as AddProductSheet that sometimes need to construct their own ViewModel, but in tests or previews should accept an existing instance. The wrapper hides the ownership details so that callers can write:

@ObservedOrStateObject private var viewModel: AddProductViewModel

and decide at initialization whether the view should own the model or observe a shared one.

Buggy Implementation

var projectedValue: ObservedObject<ObjectType>.Wrapper {
    ObservedObject(wrappedValue: box.object).projectedValue
}

Each call to $viewModel (or any other @ObservedOrStateObject-backed property) executed this computed property, creating a brand-new ObservedObject. The Combine subscription in StorageBox.refreshSubscription() was also refreshed on every update() call.

Full Source (Bug Present)

import SwiftUI
import Combine

/// Wraps an `ObservableObject` so views can either own or observe the instance without duplicating it.
@propertyWrapper
struct ObservedOrStateObject<ObjectType>: DynamicProperty where ObjectType: ObservableObject {
    @StateObject private var box: StorageBox

    init(wrappedValue value: @autoclosure @escaping () -> ObjectType) {
        _box = StateObject(wrappedValue: StorageBox(object: value()))
    }

    init(observed value: ObjectType) {
        _box = StateObject(wrappedValue: StorageBox(object: value))
    }

    var wrappedValue: ObjectType {
        box.object
    }

    var projectedValue: ObservedObject<ObjectType>.Wrapper {
        ObservedObject(wrappedValue: box.object).projectedValue
    }

    mutating func update() {
        _box.update()
        box.refreshSubscription()
    }

    private final class StorageBox: ObservableObject {
        let object: ObjectType
        private var cancellable: AnyCancellable?
        let objectWillChange = ObservableObjectPublisher()

        init(object: ObjectType) {
            self.object = object
            refreshSubscription()
        }

        func refreshSubscription() {
            cancellable = object.objectWillChange.sink { [weak self] _ in
                self?.objectWillChange.send()
            }
        }
    }
}

Symptoms in the Wild

We first noticed trouble inside AddProductSheet when validation state stopped updating after the first keystroke. The view model kept publishing changes (confirmed via logging), but the SwiftUI view ceased reacting. In other contexts, the sheet would rebuild unexpectedly, resetting field focus and dismissing temporary UI (camera sheets, toast timers, etc.).

Those glitches shared a pattern: they appeared after the view’s body was evaluated more than once, exactly when SwiftUI re-reads dynamic properties.

Root Cause

ObservedObject is a dynamic property with identity semantics, SwiftUI stores a stable instance per view. By returning a fresh wrapper every time, we violated that contract. On the first render, SwiftUI associated its storage with our wrapper. On the next render, the framework compared the new wrapper (a different instance) with the stored one, treated it as a replacement, and severed the original subscription. The new wrapper never received the objectWillChange publisher because its subscription was only set up inside the view transaction that created it.

In short: repeated recomputation meant SwiftUI could not maintain a consistent observation pipeline, so downstream views stopped reacting to the observable object.

Fixed Implementation

The revised implementation caches the wrapper and guards the subscription:

private lazy var cachedProjection = ObservedObject(wrappedValue: object).projectedValue

var projectedValue: ObservedObject<ObjectType>.Wrapper {
    cachedProjection
}

func ensureSubscriptionIsCurrent() {
    let currentID = ObjectIdentifier(object)
    guard subscriptionObjectID != currentID || cancellable == nil else { return }
    refreshSubscription()
}
  • cachedProjection preserves the identity expected by SwiftUI. The same wrapper instance is returned on every access, so the framework keeps a stable pointer to our observable object.
  • ensureSubscriptionIsCurrent() avoids repeatedly re-subscribing when the object instance has not changed, while still allowing us to resubscribe if a new object is injected (e.g., when previews or tests swap models).

Full Source (Bug Fixed)

import SwiftUI
import Combine

/// Wraps an `ObservableObject` so views can either own or observe the instance without duplicating it.
@propertyWrapper
struct ObservedOrStateObject<ObjectType>: DynamicProperty where ObjectType: ObservableObject {
    @StateObject private var box: StorageBox

    init(wrappedValue value: @autoclosure @escaping () -> ObjectType) {
        _box = StateObject(wrappedValue: StorageBox(object: value()))
    }

    init(observed value: ObjectType) {
        _box = StateObject(wrappedValue: StorageBox(object: value))
    }

    var wrappedValue: ObjectType {
        box.object
    }

    var projectedValue: ObservedObject<ObjectType>.Wrapper {
        box.projectedValue
    }

    mutating func update() {
        _box.update()
        box.ensureSubscriptionIsCurrent()
    }

    private final class StorageBox: ObservableObject {
        let object: ObjectType
        private var cancellable: AnyCancellable?
        private var subscriptionObjectID: ObjectIdentifier?
        private lazy var cachedProjection = ObservedObject(wrappedValue: object).projectedValue
        let objectWillChange = ObservableObjectPublisher()

        init(object: ObjectType) {
            self.object = object
            refreshSubscription()
        }

        func ensureSubscriptionIsCurrent() {
            let currentID = ObjectIdentifier(object)
            guard subscriptionObjectID != currentID || cancellable == nil else { return }
            refreshSubscription()
        }

        func refreshSubscription() {
            cancellable = object.objectWillChange.sink { [weak self] _ in
                self?.objectWillChange.send()
            }
            subscriptionObjectID = ObjectIdentifier(object)
        }

        var projectedValue: ObservedObject<ObjectType>.Wrapper {
            cachedProjection
        }
    }
}

Takeaways

  1. Dynamic properties need stable identity. Avoid returning freshly constructed property wrappers from computed properties.
  2. Mirror SwiftUI’s expectations. @ObservedObject and @StateObject manage lifecycles differently; wrappers that mix them must respect both models.
  3. Log symptoms, not just crashes. The bug never crashed; instrumentation around Combine publishers surfaced the mismatch.
  4. Pair runtime tests with SwiftUI semantics. SwiftUI caches dynamic properties aggressively. Verifying identity behavior in small sample views can catch issues early.

With these changes, ObservedOrStateObject now operates predictably whether a view owns or observes its model, and our add/edit product flows keep their state in sync across renders.

FAQ

What problem does the ObservedOrStateObject pattern solve in SwiftUI?
It lets a view either own its view model or observe one that is injected from the outside without duplicating code. The same property wrapper handles both cases so tests, previews, and production code can share the view implementation.
When should I consider using ObservedOrStateObject instead of plain @StateObject or @ObservedObject?
Use it when some call sites should create the view model while others should inject an existing instance. If all callers either always own or always observe the model, the built-in wrappers are simpler.

Welcome to The infinite monkey theorem

Somewhere a monkey just typed Shakespeare in TypeScript. Be the first to read the masterpieces (and the hilarious misfires) landing on the blog.

Subscribe to The infinite monkey theorem

We fling fresh posts—no banana peels attached—straight to your inbox.