Avatar of Matt Moriarity
Matt Moriarity

@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 the ObservableObject is passed in to the view directly (likely through its initializer)
  • @EnvironmentObject, for when the ObservableObject 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: MyViewModel
var body: some View { /* ... */ }
}
struct StateObjectView: View {
@StateObject var model: MyViewModel
init(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 ObservableObjects 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 loader
2020-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 = nil
variables = nil
fetchPolicy = nil
environment = nil
fetchCancellable = nil
subscribeCancellable = nil
retainCancellable = nil
isLoaded = false
}
(lldb) p loader
2020-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 = nil
variables = nil
fetchPolicy = nil
environment = nil
fetchCancellable = nil
subscribeCancellable = nil
retainCancellable = nil
isLoaded = 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.

Loading...

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: String
init(text: String) {
inputText = text
query = 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: SearchDelayer
init(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 ObservableObjects in our views, and all three have situations where they are the right choice.