@StateObservable: A property wrapper for using an @Observable class as state without optionals or onAppear
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()
...
}