Articles, podcasts and news about Swift development, by John Sundell.

A guide to SwiftUI’s state management system

Published on 05 Jul 2020
Discover page available: SwiftUI

What 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! 🚀