One of the first decisions SwiftUI developers need to make is which of the available property wrappers to use to store data. Especially in iOS 14, where the whole app lifecycle can be written with SwiftUI, storing your data the right way is essential to your app running and behaving predictably and bug-free.
The app I made to test these different property wrappers
The challenge in making a SwiftUI app is technically all four of @State, @StateObject, @ObservedObject and @EnvironmentObject will superficially “work”. Your app will compile, and you may even get the behaviour you are after even when using the wrong property wrapper for the situation.
But, if used incorrectly, you may find your view doesn’t update when your data updates. Or, your data persists for longer than you expect it to. Or, your data doesn't persist at all.
Let’s break it down in this story, beginning with @State.
State: A property wrapper type that can read and write a value managed by SwiftUI.
This is the definition of @State from Apple. But what does that mean?
State is the simplest source of truth your app can have. It is designed to contain simple value types, such as Ints, Strings, and Bools. It is not designed for more complex, reference types, such as any classes or structs you define yourself and use within your app.
A good use of state versus a bad use of state
Apple even says this — to use state while the values are simple before you add them to your model:
You might also find this kind of storage convenient while you prototype, before you’re ready to make changes to your app’s data model.
@State works by re-computing the body variable of your view any time it updates. So if you have some State in your view that keeps track of an integer, and you add 1 to the integer, your State will see this and re-render the view. As the view uses this state, you will see the number on your screen update.
Like I said earlier, this works as described for simple value types like integer, string, or boolean. However, you will find that it is possible to have @State keep track of a complex object. I’m going to refer to the following object several times throughout this story as an example:
This is how I’m using it:
This will not throw any errors. What I’ve done is created a new copy of TestObject() and marked it with the @State property wrapper, which tells SwiftUI that I want this view to keep track of it.
What do you think happens when I call state.num += 1 , as per line 8?
Well, we see in the output window with line 9 that state.num has incremented by 1, but in the view in our app, the value of state hasn’t changed. Why is this?
Clicking on the increase state button, our output window has our printed statements, but the UI isn’t updating
Let’s go back to what I wrote earlier…
@State works by re-computing the body variable of your view any time it updates.
But, because we’re using a complex, reference type, the value of state itself never changes. While a property of state, num has changed, the @State property wrapper has no idea because it is only watching the variable state, not any of its properties. To SwiftUI, because it is only watching state, it has no idea that num has changed, and so never re-renders the view.
This causes the scenario where we can see that the value is updating successfully in the console, but the body variable in our view is never re-computed and so we’re not seeing our UI update. This also causes our onChange method to never be called, as it detects changes between renders of the body variable, and if it is never rendered it is never called.
So, as we can see, @State is not the right solution for anything more than the most simple piece of data. Think uses like keeping track if an element should be visible on the screen, or for highlighting a particular row number. For anything more complex, or related to your business logic, read on…
Introduced with iOS 14 and Swift UI 2, @StateObject is…
A property wrapper type that instantiates an observable object.
That’s great Sam, but what is an “observable object”? Let’s defer to Apple once more:
A type of object with a publisher that emits before the object has changed.
What does that mean? Let me show you an example of our TestObject , but recreated as an ObservableObject:
There are two major differences here:
- TestObject now conforms to the protocol ObservableObject
- We mark the num with the property wrapper @Published
Put together, what we are saying is any time num is updated, we want to let any users of our ObservableObject know that they should re-render the view.
Let’s go back to our example view.
I’ve only made one small change, on line 2, moving from @State to @StateObject . Using @StateObject requires my object to conform to the type ObservableObject, so good thing we did that earlier.
Now, when we tap the button, we will not only see our new num value printed to our output window, but we will also see the UI update.
The view is updating, and the object is also recreated when we exit and enter the view
This is because, by using @StateObject, we are letting our view know that whenever any of the @Published properties within the Observable Object change, we want the view to re-render. In other words, whenever we update num, because that property is @Published, it tells any views who are listening to it (observing it) that the value has changed and it should re-compute its views.
We don’t face the same issue as using @State, because now it is not just simply watching the object itself, but rather listening for changes to any of its marked @Published properties. Pretty cool.
The other important thing to note with @StateObject is the lifecycle of the object is directly tied to the view. By that, I mean if you enter into your view via a NavigationLink, set the num to 3, go back in the NavigationView and then re-enter your view again, that num will be reset to 0. In fact, the whole TestObject will have been created from scratch.
While it sounds annoying that your data is reset, this is actually the behaviour that we want. If we need data within a parent view (that is, the view that moves to your view with a NavigationLink), we should define the @StateObject in the parent view, not the child. If we only need the data in the child, then define the @StateObject in the child.
Using this approach we can guarantee the data will not change or be disposed of if the view is still active. As Apple says in their documentation, this is the correct way to instantiate complex data types.
Our next two methods are to do with consuming complex objects that we have instantiated as a @StateObject.
Apple describes an @ObservedObject as:
A property wrapper type that subscribes to an observable object and invalidates a view whenever the observable object changes.
This is almost the same as an @StateObject, except it makes no mention of instantiation, or creation, of your variable.
That’s because @ObservedObject is used to keep track of an object that has already been created, probably using @StateObject.
@ObservedObject is to be used when you want to pass an Observable Object from one view to another view. You have already instantiated the Observable Object using @StateObject in the parent view, and now you want a child view to have access to the data. But, you don’t want to recreate the object again. This won’t retain any of the data within the object.
Instead, you want to pass the existing Observable Object down to the child, and this is done through @ObservedObject, through a NavigationLink like this:
And then, the child view accesses the object like this:
ChildView has one property called observedObject which accepts an object of type TestObject. It is marked as ObservedObject because it isn't instantiating it (that would be for @StateObject). Instead, it has already been instantiated and ChildView can now read and write to the same data that the parent view is reading and writing to.
However, this approach can get quite cumbersome if many children views need to access the same data. For example, you might want to know the username of a user in many different views across your app, but you don’t want to have to pass down a UserDetails model to every single view.
@EnvironmentObject is for those scenarios where you need to use an ObservableObject but the views aren’t direct parent/child pairs. You may want to use a piece of data on the home view, and also deep within a settings menu, but you don’t want (or need) every view in between to know about that data — that would make for some messy code.
This is Apple’s recommended solution, as seen here from this WWDC video screenshot:
Apple’s recommended approach for data required in many disparate views. Source: Data Essentials in SwiftUI — WWDC 2020
Their ObservableObject has been created in the very first view, and they want some views much further down to know and respond, to when it is changed.
There are two parts to using @EnvironmentObject.
Firstly, you need to create an object to use. After you create it, you then need to attach it to a view for all child views to have access to it. Let’s go ahead and do that.
Here, on line 3 I’ve created the object, and then on line 6, I’ve attached it to my view. Notice I’ve done this all in my main app file. This is a fairly common pattern for data you want accessible across your entire app.
Then, to use the object in any view that is within ContentView, you do this:
This is exactly like you would use @ObservedObject, except this time we are expecting it to be in the environment, as opposed to directly passed from the parent.
The way @EnvironmentObject works is when called within a view, it looks from an object of that type in the environment (in other words, from any parent above it that has specified an environmentObject), and then lets you use it. It does this at run time, as opposed to at compile-time, so if you haven’t set up your environment object properly, your app will crash when it goes to use it.
The other point to note is @EnvironmentObject looks for an object of that type. This means you can’t define more than one environment object in the same view tree with the same type.
@EnvironmentObject is super handy and a great tool to use when your data is used across more than a few nearby views.
So, in summary:
- Use @State for very simple data like Int, Bool, or String. Think situations like whether a toggle is on or off, or whether a dialog is open or closed.
- Use @StateObject to create any type that is more complex than what @State can handle. Ensure that the type conforms to ObservableObject, and has @Published wrappers on the properties you would like to cause the view to re-render, or you’d like to update from a view. Always use @StateObject when you are instantiating a model.
- Use @ObservedObject to allow a parent view to pass down to a child view an already created ObservableObject (via @StateObject).
- Use @EnvironmentObject to consume an ObservableObject that has already been created in a parent view and then attached via the view’s environmentObject() view modifier.
Thanks for reading this post. I hope it was useful in your pursuit of using SwiftUI.