@StateObservable: A property wrapper for using an @Observable class as state without optionals or onAppear

Published by malhal on

Health warning: In general I would not recommend this approach. View data should be @State simple values or custom structs with mutating func for testable logic, passed down as let for read only, computed vars for transforming, or @Binding var for read/write. @Observable is designed for data models, e.g. loading and saving, and usually has a singleton for the app and another filled with test data for previews.

This is an interesting gap in the SwiftUI syntax that it isn’t straight forward to correctly use an @Observable class with a lifetime tied to UI on screen like there was with @StateObject where the property wrapper was init with a closure that init the object before the UI appears and de-inits in when it disappears. Since @State is designed for value types the auto-closure is unnecessary and it simply constantly re-inits the initial value every time the View struct is re-init however the body always sees the most recently changed version of the value. If @State is used with a class, then a new unnecessary heap object is allocated every time the View struct is init, which is a bit like having a memory leak.

Apple’s samples come in 2 flavours and they actually document the problem here. In the first, they init the @Observable inline as @State which surfaces the memory leak thus is a strange thing to have in an official sample. However, someone could argue if this ContentView is only init once then the object would only be init once. I would argue what is the point in even using @State then? To muddy the waters further, if this is done in the App struct, currently it is only ever init once too. So we have these 2 possible situations where @State with inline @Observable init might actually not leak memory, but it does not set developers off on the correct foot.

The second example is when they explain the problem and say the @State should actually be optional and you should init the object once in .onAppear. This is fine but it can be tricky to know where exactly to place this .onAppear because this UI might appear multiple times and if not handled properly then can result in the same memory leak as above, except worse because it would throw away the current object and replace it with the new one. To resolve this, the developer has 2 choices, a nil check inside .onAppear to prevent re-init or re-position the .onAppear so it only happens when the object is nil, e.g. with an if and a Text("Loading object...").onAppear {. This might have resulted in clunky code like the following:

@Observable class ObservableContent: Hashable {
    var text1 = "Default"
    var text2 = ""
    
    func hash(into hasher: inout Hasher) {
        hasher.combine(ObjectIdentifier(self))
    }
    static func == (lhs: ObservableContent, rhs: ObservableContent) -> Bool {
        lhs === rhs
    }
}

struct ContentView: View {
    
    @State var observableContent: ObservableContent?
    
    var body: some View {
        Group {
            if let observableContent {
                NavigationStack {
                    NavigationLink(value: observableContent) {
                        Text("Navigation Link")
                    }
                    .navigationDestination(for: ObservableContent.self) { content in
                        ObservableContentView(content: content)
                    }
                }
            }
            else {
                //Text("Loading object...") // another place the object could be init.
                //.onAppear { ...
            }
            
        }
        .onAppear {
            if observableContent == nil {
                observableContent = ObservableContent()
            }
        }
        .onDisappear {
            observableContent = nil
        }
    }
}

The above code actually only works if it’s the root view because onAppear isn’t called on a Group that has no content when the state is nil if it isn’t the root view, hence the need for a Text placeholder. It turns out 3rd and much better solution, just also conform the class to ObservableObject (without any @Published properties) and just continue to use @StateObject, this neat trick was mentioned by Helge Heß on Mastadon.

Since neither of Apple’s suggestions are ideal I thought I would come up with my own solution until Apple adds one to the framework, a property wrapper @StateObservable. The implementation takes advantage of the old @StateObject‘s auto-closure initialisation routine to wrap the init of the @Observable which fixes both the memory leak and the need for .onAppear it also comes with the advantage that the entire object can be replaced.

import SwiftUI

@propertyWrapper struct StateObservable<T>: DynamicProperty where T: Observable & AnyObject {
    @StateObject var storage: Storage
    
    class Storage: ObservableObject {
        @Published var object: T // @Published allows the object to be replaced and body will be called
        init(object: T) {
            self.object = object
        }
    }
    
    var wrappedValue: T {
        get {
            return storage.object
        }
        nonmutating set {
            storage.object = newValue
        }
    }
    
    // this allows $ syntax to be used in the same View where the wrapper is declared.
    var projectedValue: Bindable<T> {
        get {
            Bindable(storage.object)
        }
    }
    
    @inlinable public init(wrappedValue thunk: @autoclosure @escaping () -> T) {
        _storage = StateObject(wrappedValue: Storage(object: thunk()))
    }
}

This results in View code that is much simpler and the object should only be init once regardless of how many times the View is re-init and fixes the heap memory leak.

struct ContentView: View {
    @StateObservable var observableContent = ObservableContent()
    
    var body: some View {
        NavigationStack {
            NavigationLink(value: observableContent) {
                Text("Navigation Link")
            }
            .navigationDestination(for: ObservableContent.self) { content in
                ObservableContentView(content: content)
            }
        }
    }
}

This all being said, for your view data please stick to @State simple values or group related values into custom structs and learn mutating func for testable logic instead of falling back to more familiar classes.

struct Content: Hashable {
    var text1 = "Default"
    var text2 = ""

    mutating func reset() {
        text1 = "Default"
        text2 = ""
    }
}

struct ContentView: View {
    @State var content = Content()

    ...
}
Categories: SwiftUI