@StateObject and @ObservedObject in SwiftUI
This morning I came across a tweet from Nick Lockwood asking a very good question about SwiftUI in iOS 14:
Loading tweet…In iOS 13, we had two property wrappers available for using an ObservableObject
in a SwiftUI view:
@ObservedObject
, for when theObservableObject
is passed in to the view directly (likely through its initializer)@EnvironmentObject
, for when theObservableObject
is passed to the view indirectly through the environment
These two property wrappers left a very real gap in functionality: what if I want my view to own the ObservableObject
? Both @ObservedObject
and @EnvironmentObject
rely on an external source of truth for the object.
iOS 14 closes this gap by adding @StateObject
, which is kind of like if you mashed @State
and @ObservedObject
together. Like @State
, the view owns the value and the value's lifecycle is tied to the view's lifecycle. And like @ObservedObject
, the view will update when the object's objectWillChange
publisher fires.
Now that we have @StateObject
, Nick is right to wonder what the purpose is of using @ObservedObject
anymore. Do we still need it? We've been told through docs and WWDC session videos that @StateObject
is for when your view owns the object, and @ObservedObject
is for when something else owns it. What does that mean in practice? Let's look more closely at how they work to get a better understanding of that.
The crux of Nick's question is asking what the difference between these two views is:
struct ObservedObjectView: View {@ObservedObject var model: MyViewModelvar body: some View { /* ... */ }}struct StateObjectView: View {@StateObject var model: MyViewModelinit(model: MyViewModel) {_model = StateObject(wrappedValue: model)}var body: some View { /* ... */ }}
In StateObjectView
, we're creating a new StateObject
to use instead of letting the property wrapper syntax initialize it. This lets us pass values from the initializer into the wrapped value. This is a lesser known way of using property wrappers, but it can be valuable sometimes.
On the surface, it looks like these are doing the same thing. They're both passing in a value to a view and assigning it to a property that is wrapped in a property wrapper. In many cases, these will behave the same. So what's different about them?
One clue to this can be found by looking at the initializer for StateObject
:
init(wrappedValue thunk: @autoclosure @escaping () -> ObjectType)
The value you pass in for wrappedValue
gets wrapped in an escaping closure. In fact, the documentation for this parameter says: "An initial value for the state object." It's wrapped in a closure because most of the time, this value will not even be evaluated. The first time your view renders, the wrappedValue
will be used to give an initial value to the property, but after that, it will be ignored.
This is critical to @StateObject
's ability to be a source of truth. Remember that SwiftUI views are value types. When something changes if a view's state, the body of the view will be evaluated again against the new state. View bodies involve creating a bunch of new instances of the subviews they declare, which is fine because views are cheap. But that means that a new view struct may be created many times for same "instance" of that view. The @State
and @StateObject
property wrappers exist to allow data to be carried over to the new view struct.
They do this by storing their values outside of the struct. @State
and @StateObject
use storage managed by SwiftUI that is tied to the lifetime of the view rather than a particular instance of a View struct.
You can see some of how this works in the debugger. I'll use some examples from a Relay.swift app I'm working on. Relay.swift uses ObservableObject
s a fair bit, and @StateObject
is a natural fit. In these examples, I have a loader
property that is declared as @StateObject var loader = QueryLoader()
.
First let's look at the loader
property when a view struct is being initialized:
Here we can see the storage I mentioned above. Right now, all that it contains is the closure for the initial value provided in the declaration (the wrappedValue
). This storage isn't bound to a view yet, because the view is still being created, so StateObject
is just hanging on to the initial value in case it needs it. At this moment, we can technically access the loader
property, but it won't really do what we might expect:
(lldb) p loader2020-07-03 09:47:09.651058-0600 Speedrun[40350:1875673] [SwiftUI] Accessing StateObject's object without being installed on a View. This will create a new instance each time.(RelaySwiftUI.QueryLoader<Speedrun.GameSearchScreenQuery>) $R0 = 0x00007f9dbe0053f0 {result = nilvariables = nilfetchPolicy = nilenvironment = nilfetchCancellable = nilsubscribeCancellable = nilretainCancellable = nilisLoaded = false}(lldb) p loader2020-07-03 09:47:12.070878-0600 Speedrun[40350:1875673] [SwiftUI] Accessing StateObject's object without being installed on a View. This will create a new instance each time.(RelaySwiftUI.QueryLoader<Speedrun.GameSearchScreenQuery>) $R2 = 0x00007f9dbbe06a10 {result = nilvariables = nilfetchPolicy = nilenvironment = nilfetchCancellable = nilsubscribeCancellable = nilretainCancellable = nilisLoaded = false}
SwiftUI warns us that since the view isn't installed yet, it can't access the real storage for this property, because it doesn't know which view's storage to use. So its fallback behavior is to just call the closure we gave it for the initial value and return that to us. We get a valid QueryLoader
, but it's a new one each time. We should heed this warning and not try to do anything with this object, as it won't have any affect on our views.
Okay, now let's look at the loader
property once we're actually rendering the view body.
Cool, now we have an actual object inside the storage! SwiftUI created some storage for the view, and used our initial value closure to populate it. Accessing the loader
property now will return the same instance instead of creating a new one each time.
Now if I'm in my app and I interact with it and change some other state, SwiftUI renders my views again and my view may end up getting re-initialized. When it does, the loader
property looks the same as the first time: it has unbound storage with my initial value closure. Then, when the body renders, my loader
looks like this:
This time, SwiftUI never used the initial value I provided. I could verify this with a breakpoint in QueryLoader
's initializer: it's only called once. SwiftUI found the existing storage for the view, so it gives me back the same value for loader
as the first time. Look at the addresses of wrappedValue
: they're the same.
This shows us that regardless of what we pass into the wrappedValue
for a StateObject
, for the lifetime of our view, we will always get the exact same object out. Whatever that value was the first time the view was rendered, that's what we'll get.
And this is why we still need @ObservedObject
for objects that are created and live outside the view. The most common way we would use those is to pass an object in through the view's initializer. We would expect that if we had some conditional that caused us to pass a different ObservableObject
sometimes, then the view we are passing it to would update and use the new object. With @ObservedObject
, that's true: the view will always use the object you've passed in. With @StateObject
, that's not true: as we've seen above, a @StateObject
property always returns the same object for the lifetime of the view. There's no way to create a StateObject
that doesn't have this behavior.
To demonstrate the difference, I created a Swift Playground with a small app with two counters.
There's a Counter
ObservableObject
type that powers the views, and there are two different versions of the counter view. Both versions take a Counter
from the parent view, but one does it with ObservedObject
and one does it with StateObject
. There's a button in the parent view to choose which of two Counter
instances to pass to the child views. Initially, they are both using the same instance: when you increment or decrement one counter, they both update. But if you press the button to switch the counters, only the one using ObservedObject
actually changes. The StateObject
one continues to use the counter it was passed when it was first created.
There is still sometimes a use for creating a StateObject
manually in your initializer. It's the same reason you may want to do it for State
: if you specifically want to set an initial value based on a parameter to your view, and then let your view manage the state from there.
In one of my apps, I have a search field that's initially empty. When the user types into it, that updates some state, but I don't want to immediately fetch new data every time it changes. Instead, I only want to fetch the results once they stop typing for a bit. I captured this behavior in an ObservableObject
:
class SearchDelayer: ObservableObject {// The text the user has typed in.// This updates with every keystroke.@Published var inputText: String// The text to use for the query.// The view watches for changes to this property to// fetch new data.@Published var query: Stringinit(text: String) {inputText = textquery = text$inputText.debounce(for: .milliseconds(500), scheduler: DispatchQueue.main).assign(to: $query)}}
The SearchDelayer
should be owned by my search screen view, so a StateObject
makes sense for it. Since the search field starts out empty, I could declare it like this:
struct GameSearchScreen: View {@StateObject private var searchDelayer = SearchDelayer(text: "")}
But this makes things challenging when I want to add previews for my view. I'd like to be able to make a preview where the search query is already filled in, so instead I pass the initial query into GameSearchScreen
and populate the initial value of the searchDelayer
field there:
struct GameSearchScreen: View {@StateObject private var searchDelayer: SearchDelayerinit(initialQuery: String = "") {_searchDelayer = StateObject(wrappedValue: SearchDelayer(text: initialQuery))}// ...}
Now I can create a GameSearchScreen
with a particular initial value. The GameSearchScreen
still owns the state here: if a parent view passes in a new initialQuery
value later, it's not actually going to affect anything, and that's exactly what I want.
I hope this shows a little more about how State
and StateObject
properties work in SwiftUI. In iOS 14, we now have three ways to use ObservableObject
s in our views, and all three have situations where they are the right choice.