SwiftUI’s new @Observable
macro is not a drop-in replacement for ObservableObject
. I learned of a subtle difference in behavior the hard way. Hopefully, I can save you from the same headache I experienced and save you some time.
Background
The new @Observable
macro was introduced last year with iOS 17 and macOS 14. It was advertised as (mostly) a drop-in replacement for ObservableObject
, but there is a significant behavior difference regarding initialization. You need to use the @StateObject
property wrapper with ObservableObject
and use the @State
property wrapper with @Observable
. This is the key difference and the underlying cause for the difference in initialization behavior. In other words, the issue is not really with ObservableObject
and @Observable
, but with their associated property wrappers.
The @StateObject
property wrapper initializer has the definition:
init(wrappedValue thunk: @autoclosure @escaping () -> ObjectType)
The @State
property wrapper initializer has the definition:
init(wrappedValue value: Value)
I think the official migration guide should have a loud warning about this, but unfortunately it does not.
This is a significant difference. @StateObject
receives an @autoclosure
for the wrappedValue
parameter while @State
simply receives the value. The result is that @StateObject
can not only defer initialization of the received value, but it will only ever initialize the value once. When using @State
, the initializer for Value
will be called every single time SwiftUI destroys and rebuilds the view hierarchy. Depending on how you’ve constructed your views and types, this is either a non-issue or results in incredibly confusing and buggy behavior that is not obvious unless you are aware of this subtle difference.
Example
Let’s look at a brief example of the differences. First, we’ll consider the “old” way of doing things using ObservableObject
and @StateObject
.
class MyModel: ObservableObject {
@Published var value = MyValue()
// ...
}
@main
struct MyApp: App {
@StateObject private var model = MyModel()
var body: some Scene {
WindowGroup {
MainView()
.environmentObject(model)
}
}
}
Next, let’s convert this example to use @Observable
and @State
by following the migration guide.
@Observable
class MyModel {
var value = MyValue()
// ...
}
@main
struct MyApp: App {
@State private var model = MyModel()
var body: some Scene {
WindowGroup {
MainView()
.environment(model)
}
}
}
The bug
Unfortunately, switching to @Observable
caused a significant and difficult to debug problem in my app. There were three issues with how I had architected my app using ObservableObject
.
- First, unlike the example code above, I was declaring my
@StateObject
from within my main rootView
object — not at theApp
level. - Next, in the initializer of my
ObservableObject
, I was loading cached data fromUserDefaults
and storing these values in my@Published
properties. - Finally, my
ObservableObject
was registered to listen for theNS/UIApplication.willTerminateNotification
notification and saved its data back toUserDefaults
when quitting the app.
Here’s a simplified version of what the code looked like after migrating to @Observable
.
@Observable
class MyModel {
var value = MyValue()
// ...
init() {
// read cached values from UserDefaults
value = UserDefaults.standard.object(forKey: "my_value")
NotificationCenter.default
.publisher(for: .willTerminateNotification)
.sink {
// save values to UserDefaults
UserDefaults.standard.set(value, forKey: "my_value")
}
.store(in: &cancellables)
}
}
struct MainView: View {
@State private var model = MyModel()
var body: some View {
// ...
}
}
When using ObservableObject
before the migration, the code above behaved as you would expect. My data would load and save correctly. I was using UserDefaults
because I only needed to store a few lightweight values and I wanted to keep everything simple. I was using app lifecycle notifications because SwiftUI scene phase does not work.
The result of migrating to @Observable
(using the code above) was unpredictable behavior because of the changes in initialization that I described above. Previously, my ObservableObject
(remember, using @StateObject
) was only ever initialized once, because the initializer for @StateObject
receives an @autoclosure
. This meant that reading from UserDefaults
only occurred once and registering for the notifications only occurred once for the lifetime of the app.
However, with @Observable
(and thus, @State
), the initializer for MyModel
is called every single time SwiftUI decides to rebuild the view. Under-the-hood, when state changes occur, SwiftUI will destroy and rebuild the view. This means your view struct (in this case, MainView
) will get re-initialized often. This implicitly re-initializes all @State
variables, too. However, SwiftUI preserves the original @State
property value and applies that value before initialization completes. The flow looks like this:
- Initialize the
@State
variables (these are new objects) - Call the
View
initializer - Reset the
@State
variables back to the previously initialized and stored objects (discarding objects initialized in step 1) - Initialization is complete
- Execute the view’s
body
(using the correct values from step 3)
What I observed is that all those new, initial instances of MyModel
(from step 1 above) linger around in memory indefinitely. I verified this via logging and Xcode’s memory graph debugger. Occasionally and non-deterministically, some of those MyModel
instances will get deallocated. This seems like a bug in SwiftUI.
Note that the View
still displays the correct data because the original object is reset to the @State
property (step 3 above).
The reason this became a problem in my particular situation was because my model object was listening for the notification, NS/UIApplication.willTerminateNotification
. All those extra instances of MyModel
lingering around in memory were still observing this notification. When triggered, each instance would save whatever data it had to UserDefaults
, resulting in a non-deterministic “last write wins” scenario. Upon relaunching the app, who knows was random data would load from UserDefaults
. Yikes. This was admittedly not the best design, but it worked well for my purposes.
The fix
I suppose the moral of this story is, “I was holding it wrong.” But honestly, if you cannot design your API such that a user is unable to hold it wrong in the first place, then it is not entirely the user’s fault when things go wrong. However, I do think the fact that random @State
objects linger around somewhere in memory in the opaque abyss of SwiftUI’s state management mechanism is a real problem.
The correct way to architect your app is to store all (app-level or global) @State
properties in your top-level App
struct, which does not get repeatedly destroyed and rebuilt like SwiftUI View
objects. (And really, your @StateObject
properties should also be declared in your App
struct too.) There were some technical reason why I did not initially do this, but the details are not important. I was able to rework my design to make it work. If you cannot do that, then you need to ensure whatever object you store in @State
does nothing in init()
and does not manage any state in response to app lifecycle notifications or something similar. Because scene phase is broken in a number of ways, I recommend using NS/UIApplicationDelegateAdaptor
instead. Only scene-level or view-specific state properties should be declared at the view-level.
An aside: when using @State
you will also want to ensure that your object’s initializer does not perform any expensive operations either. That will also result in a very bad time for you.
Additional reading
These posts on other initialization quirks of @State
and @StateObject
are also worth reading.