A guide to SwiftUI’s state management system
Discover page available: SwiftUIWhat separates SwiftUI from Apple’s previous UI frameworks isn’t just how views and other UI components are defined, but also how view-level state is managed throughout an app that uses it.
Rather than using delegates, data sources, or any of the other state management patterns that are commonly found in imperative frameworks like UIKit and AppKit — SwiftUI ships with a handful of property wrappers that enable us to declare exactly how our data is observed, rendered and mutated by our views.
This week, let’s take a closer look at each of those property wrappers, how they relate to each other, and how they make up different parts of SwiftUI’s overall state management system.
State properties
Since SwiftUI is primarily a UI framework (although it’s starting to gain APIs for defining higher-level constructs, like apps and scenes, as well) its declarative design shouldn’t necessarily need to influence the entire model and data layer of an app — but rather just the state that’s being directly bound to our various views.
For example, let’s say that we’re working on a SignupView
that enables users to sign up for a new account within an app, by entering a username
and an email
address. We’ll then use those two values to form a User
model, which is passed to a handler
closure — giving us three pieces of state:
struct SignupView: View {
var handler: (User) -> Void
var username = ""
var email = ""
var body: some View {
...
}
}
Since just two of those three properties — username
and email
— are actually going to be modified by our view, and since those two pieces of state can be kept private, we’ll mark them both using SwiftUI’s State
property wrapper — like this:
struct SignupView: View {
var handler: (User) -> Void
@State private var username = ""
@State private var email = ""
var body: some View {
...
}
}
Doing so will automatically create a connection between those two values and our view itself — meaning that our view will be re-rendered every time either of those values are changed. Within our body
, we’ll then bind each of those two properties to a corresponding TextField
in order to make them user-editable — giving us the following implementation:
struct SignupView: View {
var handler: (User) -> Void
@State private var username = ""
@State private var email = ""
var body: some View {
VStack {
TextField("Username", text: $username)
TextField("Email", text: $email)
Button(
action: {
self.handler(User(
username: self.username,
email: self.email
))
},
label: { Text("Sign up") }
)
}
.padding()
}
}
So State
is used to represent the internal state of a SwiftUI view, and to automatically make a view update when that state was changed. It’s therefore most often a good idea to keep State
-wrapped properties private
, which ensures that they’ll only be mutated within that view’s body (attempting to mutate them elsewhere will actually cause a runtime crash).
Two-way bindings
Looking at the above code sample, the way we pass each of our properties into their TextField
is by prefixing those property names with $
. That’s because we’re not just passing plain String
values into those text fields, but rather bindings to our State
-wrapped properties themselves.
To explore what that means in greater detail, let’s now say that we wanted to create a view that lets our users edit the profile information that they originally entered when signing up. Since we’re now looking to modify external state values, rather than just private ones, we’ll mark our username
and email
properties as Binding
this time:
struct ProfileEditingView: View {
@Binding var username: String
@Binding var email: String
var body: some View {
VStack {
TextField("Username", text: $username)
TextField("Email", text: $email)
}
.padding()
}
}
What’s cool is that bindings are not just limited to single built-in values, such as strings or integers, but can be used to bind any Swift value to one of our views. For example, here’s how we could actually pass our User
model itself into ProfileEditingView
, rather than passing two separate username
and email
values:
struct ProfileEditingView: View {
@Binding var user: User
var body: some View {
VStack {
TextField("Username", text: $user.username)
TextField("Email", text: $user.email)
}
.padding()
}
}
Just like how we’ve been prefixing our State
and Binding
-wrapped properties with $
when passing them into various TextField
instances, we can do the exact same thing when connecting any State
value to a Binding
property that we’ve defined ourselves as well.
For example, here’s an implementation of a ProfileView
that keeps track of a User
model using a State
-wrapped property, and then passes a binding to that model when presenting an instance of the above ProfileEditingView
as a sheet — which will automatically sync any changes that the user makes into that original State
property’s value:
struct ProfileView: View {
@State private var user = User.load()
@State private var isEditingViewShown = false
var body: some View {
VStack(alignment: .leading, spacing: 10) {
Text("Username: ")
.foregroundColor(.secondary)
+ Text(user.username)
Text("Email: ")
.foregroundColor(.secondary)
+ Text(user.email)
Button(
action: { self.isEditingViewShown = true },
label: { Text("Edit") }
)
}
.padding()
.sheet(isPresented: $isEditingViewShown) {
VStack {
ProfileEditingView(user: self.$user)
Button(
action: { self.isEditingViewShown = false },
label: { Text("Done") }
)
}
}
}
}
Note how we’re also able to mutate a State
-wrapped property simply by assigning a new value to it — like how we set isEditingViewShown
to false
within our ”Done” button’s action handler.
So a Binding
-marked property provides a two-way connection between a given view and a state property defined outside of that view, and both State
and Binding
-wrapped properties that can be passed as bindings by prefixing their property name with $
.
Observing objects
What both State
and Binding
have in common is that they deal with values that are managed within a SwiftUI view hierarchy itself. However, while it’s certainly possible to build an app that keeps all of its state within its various views — that’s not typically a good idea in terms of architecture and separation of concerns, and can easily lead to our views becoming quite massive and complex.
Thankfully, SwiftUI also provides a number of mechanisms that enable us to connect external model objects to our various views. One such mechanism is the ObservableObject
protocol which, when combined with the ObservedObject
property wrapper, lets us set up bindings to reference types that are managed outside of our view layer.
As an example, let’s update the ProfileView
that we defined above — by moving the responsibility of managing our User
model out from the view itself and into a new, dedicated object. Now, there are a number of different metaphors that we could use to describe such an object, but since we’re looking to create a type that’ll control an instance of one of our models — let’s make it a model controller that conforms to SwiftUI’s ObservableObject
protocol:
class UserModelController: ObservableObject {
@Published var user: User
...
}
The Published
property wrapper is used to define which of an object’s properties that should cause an observation notification to be triggered when modified.
With the above type in place, let’s now go back to our ProfileView
and make it observe an instance of our new UserModelController
as an ObservedObject
, rather than using a State
-wrapped property to keep track of our User
model. What’s really neat is that we can still easily bind that model to our ProfileEditingView
, just like before, since ObservedObject
-wrapped properties can also be converted into bindings — like this:
struct ProfileView: View {
@ObservedObject var userController: UserModelController
@State private var isEditingViewShown = false
var body: some View {
VStack(alignment: .leading, spacing: 10) {
Text("Username: ")
.foregroundColor(.secondary)
+ Text(userController.user.username)
Text("Email: ")
.foregroundColor(.secondary)
+ Text(userController.user.email)
Button(
action: { self.isEditingViewShown = true },
label: { Text("Edit") }
)
}
.padding()
.sheet(isPresented: $isEditingViewShown) {
VStack {
ProfileEditingView(user: self.$userController.user)
Button(
action: { self.isEditingViewShown = false },
label: { Text("Done") }
)
}
}
}
}
However, an important difference between our new implementation and the State
-based one that we used before is that our UserModelController
now needs to be injected into our ProfileView
as part of its initializer.
The reason for that, apart from that it “forces” us to establish a more well-defined dependency graph within our code base, is that a property marked with ObservedObject
doesn’t imply any form of ownership over the object that such a property points to.
So while something like the following might technically compile, it can end up causing runtime issues — as the UserModelController
instance that’s stored within our view could end up being deallocated when our view gets recreated during an update (as our view is now the primary owner of it):
struct ProfileView: View {
@ObservedObject var userController = UserModelController.load()
...
}
It’s important to remember that SwiftUI views are not references to the actual UI components that are being rendered on screen, but rather lightweight values that describe our UI — so they don’t have the same kind of lifecycle as something like a UIView
instance has.
To fix the above problem, Apple introduced a new property wrapper as part of iOS 14 and macOS Big Sur called StateObject
. A property marked with StateObject
behaves the exact same way as an ObservedObject
does — with the addition that SwiftUI will ensure that any object stored within such a property won’t get accidentally released as the framework recreates new instances of a view when re-rendering it:
struct ProfileView: View {
@StateObject var userController = UserModelController.load()
...
}
Although it’s technically possible to only use StateObject
from now on — I still recommend using ObservedObject
when observing external objects, and to only use StateObject
when dealing with objects that are owned by a view itself. Think of StateObject
and ObservedObject
as the reference type equivalents to State
and Binding
, or the SwiftUI versions of strong and weak properties.
Observing and modifying the environment
Finally, let’s take a look at how SwiftUI’s environment system can be used to pass various pieces of state between two views that are not directly connected to each other. Although it’s often easy to create bindings between a parent view and one of its children, it can be quite cumbersome to pass a certain object or value around within a whole view hierarchy — and that’s exactly the type of problem that the environment aims to solve.
There are two main ways to use SwiftUI’s environment. One is to start by defining an EnvironmentObject
-wrapped property within the view that wants to retrieve a given object — for example like how this ArticleView
retrieves a Theme
object which contains color information:
struct ArticleView: View {
@EnvironmentObject var theme: Theme
var article: Article
var body: some View {
VStack(alignment: .leading) {
Text(article.title)
.foregroundColor(theme.titleTextColor)
Text(article.body)
.foregroundColor(theme.bodyTextColor)
}
}
}
We then have to make sure to supply our environment object (a Theme
instance in this case) within one of our view’s parents, and SwiftUI will take care of the rest. That’s done using the environmentObject
modifier, for example like this:
struct RootView: View {
@ObservedObject var theme: Theme
@ObservedObject var articleLibrary: ArticleLibrary
var body: some View {
ArticleListView(articles: articleLibrary.articles)
.environmentObject(theme)
}
}
Note that we don’t need to apply the above modifier to the exact view that will use our environment object — we can apply it to any view that’s above it within our hierarchy.
The second way to use SwiftUI’s environment system is by defining a custom EnvironmentKey
— which can then be used to assign and retrieve values to and from the built-in EnvironmentValues
type:
struct ThemeEnvironmentKey: EnvironmentKey {
static var defaultValue = Theme.default
}
extension EnvironmentValues {
var theme: Theme {
get { self[ThemeEnvironmentKey.self] }
set { self[ThemeEnvironmentKey.self] = newValue }
}
}
With the above in place, we can now mark our view’s theme
property using the Environment
property wrapper (rather than EnvironmentObject
), and pass in the key path of the environment key that we wish to retrieve a value for:
struct ArticleView: View {
@Environment(\.theme) var theme: Theme
var article: Article
var body: some View {
VStack(alignment: .leading) {
Text(article.title)
.foregroundColor(theme.titleTextColor)
Text(article.body)
.foregroundColor(theme.bodyTextColor)
}
}
}
A notable difference between the above two approaches is that the key-based one requires us to define a default value at compile time, while the EnvironmentObject
-based approach assumes that such a value will be supplied at runtime (and failing to do so will cause a crash).
Conclusion
The way that SwiftUI manages state is definitely one of the most interesting aspects of the framework, and might require us to slightly rethink how data is passed around within an app — at least when it comes to the data that’ll be directly consumed and mutated by our UI.
I hope that this guide has served as a nice way to get an overview of SwiftUI’s various state handling mechanisms, and although some of the more specific APIs were left out (for example the preferences system that we took a look at in part two of the layout system guide), the concepts highlighted in this article should cover the vast majority of all SwiftUI-based state handling use cases.
If you have questions, comments, or feedback — feel free to reach out either via Twitter or email.
Thanks for reading! 🚀