Nested Observable Objects in SwiftUI

This one often starts with the phrase:

Hey, why isn’t my view updating? It shows the initial data, but it doesn’t update when that data gets changed!

… more than one person, including me …

When you get into seeing the code for the view, how it’s formed, and what the models look like, you see a pattern appear: nested objects that conform to ObservableObject with a reference from one to another, a top level object passed into the view, and view elements that follow the dot-notation chain to create the display.

Let me show you some code, a very simplified representation of this pattern:

class MainThing : ObservableObject {
    @Published var element : SomeElement
    init(element : SomeElement) {
        self.element = element
    }
}

class SomeElement : ObservableObject {
    @Published var value : String
    init(value : String) {
        self.value = value
    }
}

And a view that displays it:

struct MainThingView: View {
    @ObservedObject var model : MainThing
    var body: some View {
        HStack {
            Text("Detail:")
            Text(model.element.value)
        }
    }
}

At first blush, this looks fine – the view displays, and property within the nested view is shown as you’d expect; so what’s the problem? The issue is when you update that nested element’s property, even though it’s listed as @Published, the change doesn’t propagate to the view.

I’ve seen this pattern described as “nested observable objects”, and it’s a subtle quirk of SwiftUI and how the Combine ObservableObject protocol works that can be surprising. You can work around this, and get your view updating with some tweaks to the top level object, but I’m not sure that I’d suggest this as a good practice. When you hit this pattern, it’s a good time to step back and look at the bigger picture. What are you setting up with your views and models, and can you make them a more aligned to a direct representation. Let me explain what’s happening, how you can work around it, and you can judge for yourself.

This pattern is at the intersection of Combine and SwiftUI, and specific to classes. There are two parts to it, the first of which is the ObservableObject protocol, and the second part is one or more properties on that object that have the @Published property wrapper. An object that conforms to the observable object protocol has a publisher on it with a specific name: objectWillChange. You can dig around a bit, and you’ll find what it publishes may not be what you expect: it isn’t publishing the values changing, just that something will change. The type aliases for this publisher point to a type signature of Publisher<Void, Never>. Not what I expected when I first uncovered it, and it made me scratch my head.

The idea, as I understand it, is that the publisher is specifically meant to provide a signal that something has changed – but not the details of what changed. From there, the code within the SwiftUI framework (which uses it), invalidates any relevant view, and looks up what it needs from the referenced object to display a new view.

When most folks use this protocol, they’re not creating the publisher – they’re letting the swift compiler do the heavy lifting, which synthesizes the code to create it, and with the @Published property wrapper, to hook up and watch the properties that should trigger it.

So here’s the kicker to what’s happening: The @Published property wrapper watches for the properties to have changed. We’re dealing with a class here, so we’re in the world of reference semantics. When you update something in a class, you’re not updating the reference to the class – the reference stays the same. That’s the benefit (and trouble) with reference semantics – it’s not entirely obvious that something down below that reference was updated, but as a benefit – you’re not having to copy around the world of what’s in there. If you had replaced the element property with a new instance of SomeElement, then it would trigger the publisher.

So what can you do to work around this? If you’re really tied to this nested class object structure, then you change the objects a bit to “manually” support notifying that publisher chain when the property within a nested object has updated, and you want that reflected in a SwiftUI view. One way to tackle this is to add your own connections from the synthesized publishers on internal @Published property wrappers to the synthesized subject that ObservableObject provides.

The @Published property wrapper synthesizes a publisher for you, referenced with a $ preceding the property name. The compiler synthesizes a Subject for ObservableObject. The subject has a send() method on it that you can invoke. Invoking send() doesn’t require any arguments – it’s not sending any specific data – instead it’s a trigger to say “publish the fact that something is changing and views should be invalidated and redisplayed”. The code below explicitly connects the publisher synthesized within SomeElement to the subject in MainThing.

class MainThing : ObservableObject {
    @Published var element : SomeElement
    var cancellable : AnyCancellable?
    init(element : SomeElement) {
        self.element = element
        self.cancellable = self.element.$value.sink(
            receiveValue: { [weak self] _ in
                self?.objectWillChange.send()
            }
        )
    }
}

There’s a whole world of problems with this setup: from breaking the idea of encapsulation across objects to the fact that it’s incredibly fragile. If you change the element property within MainThing to a new instance, you also need to re-establish the publisher chain to the objectWillChange subject. You can manually create your own subject, such as a PassthroughSubject<Void, Never> to your top-level class and manage the connections to invoke send() on it.

This whole pattern seems to be a “code smell“. If you follow this primrose path, you’ll find yourself triggering the “invalidate and redisplay” at a high level, perhaps more often than you want. A large set of those additional connections, especially with a model changing regularly, pretty quickly leads to performance issues, as the effects invalidate larger swaths of view hierarchy for potentially small changes. One or two connections like this won’t hurt much, but more certainly can.

What seems to be better advice is to look closely at your views, and revise them to make more, and more targeted views. Structure your views so that each view displays a single level of the object structure, matching views to the classes that conform to ObservableObject. In the case above, you could make a view for displaying SomeElement (or even several views) that display’s the property from it that you want shown. Pass the property element to that view, and let it track the publisher chain for you.

struct FocusedView: View {
    @ObservedObject var element : SomeElement
    var body: some View {
        Text(element.value)
    }
}

struct MainThingView: View {
    @ObservedObject var model : MainThing
    var body: some View {
        HStack {
            Text("Detail:")
            FocusedView(element: model.element)
        }
    }
}

This pattern implies making more, smaller, and focused views, and lets the engine inside SwiftUI do the relevant tracking. Then you don’t have to deal with the book keeping, and your views potentially get quite a bit simpler as well.

Published by heckj

Joe has broad software engineering development and management experience, from startups to large companies. Joe works on projects ranging from mobile to multi-cloud distributed systems, has set up and led engineering teams and processes, as well as managing and running services. Joe also contributes and collaborates with a wide variety of open source projects, and writes online at https://rhonabwy.com/.

4 thoughts on “Nested Observable Objects in SwiftUI

    1. Thanks Chris – I’d recently updated the theme without looking at it closely and hadn’t noticed the low-contrast text. I’ve updated it now – should be far more easily readable.

      Like

Comments are closed.

<span>%d</span> bloggers like this: