Skip to content

Data Flow Property Wrappers

Posted on:March 28, 2023

Now that we’ve built some of our own views, we should think about how they can be dynamic. As they’re built today, they have static text and images. SwiftUI gives us the tools to manage the state of views, such that changes can be communicated. We’re going to explore the four property wrappers that SwiftUI provides us with: @State, @StateObject, @Binding, and @ObservedObject. We’ll learn about their differences, when to use each one, their pros and cons, how they impact our ability to test these views, as well as common pitfalls and mistakes.

@State and @Binding

@State and @Binding are property wrappers that work with value types, such as structs and enums.

@State

@State is a property wrapper that can be used to store simple data that is private to a view. For example, we can use @State to store a boolean flag that controls whether a modal sheet is presented or not. When a property is marked with @State, SwiftUI creates a source of truth for that property and manages its storage. Whenever the property changes, SwiftUI updates the view automatically.

@State should be used when:

@State is the easiest of the set to use and understand, doesn’t require any additional types or protocols, and works well with animations and transitions. However, it cannot be shared between multiple views or across the app, it cannot store complex types or reference types (it cannot store any classes), and perhaps most crucially: it cannot be tested. Since @State is private to the view, we cannot inspect on state from an external source.

Let’s setup a view that uses @State to store a boolean flag that controls whether a modal sheet is presented or not:

struct ContentView: View {
    // A state property that stores a boolean flag
    @State private var isSheetPresented = false

    var body: some View {
        Button("Show Sheet") {
            // Toggle the flag when the button is tapped
            isSheetPresented.toggle()
        }
        .sheet(isPresented: $isSheetPresented) {
            // Present a sheet based on the flag
            SheetView()
        }
    }
}

@Binding

A @Binding is a property wrapper that can be used to create a two-way connection between a property that is stored in one view and a property that is exposed by another view. For example, we can use a @Binding to pass a boolean flag from a parent view to a child view that controls whether a modal sheet is presented or not. When a property is marked with @Binding, SwiftUI creates a two-way connection between the property and the source of truth. This means that we can read and write the property value, and any changes will be reflected in both places.

A @Binding should be used when:

A @Binding is a great way of sharing state between views, and can help create reusable views that accept different data sources. It helps avoid creating multiple sources of truth for the same data, and it simplifies the communication between views without using delegates or callbacks. However, using a @Binding requires creation of a source of truth — such as @State. It’s best used for simple types — enums, Bools, Ints, and the like — instead of complex data and reference types. Similar to State, a @Binding is difficult to test because it depends on the parent view’s state.

Let’s examine an example of using a @Binding to pass a boolean flag from a parent view to a child view that controls whether a modal sheet is presented or not:

struct ParentView: View {
    // A state property that stores a boolean flag
    @State private var isSheetPresented = false

    var body: some View {
        VStack {
            // A child view that accepts a binding for the flag
            ChildView(isSheetPresented: $isSheetPresented)
            // A button that toggles the flag
            Button("Toggle Sheet") {
                isSheetPresented.toggle()
            }
        }
        .sheet(isPresented: $isSheetPresented) {
            // Present a sheet based on the flag
            SheetView()
        }
    }
}

struct ChildView: View {
    // A binding property that connects to the parent's state
    @Binding var isSheetPresented: Bool

    var body: some View {
        // A text view that displays the flag value
        Text("Is sheet presented: \(isSheetPresented.description)")
    }
}

@ObservedObject and @StateObject

@ObservedObject and @StateObject are property wrappers that work with reference types.

@ObservedObject

@ObservedObject is a property wrapper that you use to store an observable object that is passed into a view. An observable object is a class that conforms to the ObservableObject protocol and has one or more properties marked with @Published. When any of the published properties change, SwiftUI updates any views that are observing the object.

@ObservedObject should be used when:

An @ObservedObject is a phenomenal tool for enabling communication and synchronization between multiple views and in practice is used the same way that a Binding is used, but for references types. It requires the creation of a separate class that conforms to ObservableObject and mark its properties with @Published. It can cause memory leaks if properties aren’t marked with weak or unowned in closures that capture it. It can cause unexpected behavior if new instances of the object are created inside the view.

Let’s examine using @ObservedObject to create a counter:

// An observable object that contains a counter
class CounterViewModel: ObservableObject {
    // A published property that stores the counter value
    @Published var count = 0

    // A method that increments the counter
    func incrementCounter() {
        count += 1
    }
}

struct ParentView: View {
    // An observed object that creates an instance of CounterViewModel
    @ObservedObject var viewModel = CounterViewModel()

    var body: some View {
        VStack {
            // A text view that displays the counter value
            Text("Count is: \(viewModel.count)")
            // A button that calls the increment method on the view model
            Button("Increment Counter") {
                viewModel.incrementCounter()
            }
            // A child view that accepts an observed object for the view model
            ChildView(viewModel: viewModel)
        }
    }
}

struct ChildView: View {
    // An observed object that connects to the parent's view model
    @ObservedObject var viewModel: CounterViewModel

    var body: some View {
        // A text view that displays the counter value
		Text(“Count is: (viewModel.count)”)
	}
}

@StateObject

@StateObject is a property wrapper that can be used to store an observable object that is created by a view. An observable object is a class that conforms to the ObservableObject protocol and has one or more properties marked with @Published. When any of the published properties change, SwiftUI updates any views that are observing the object.

A @StateObject is used very similarly to an @ObservableObject. For all intents and purposes, they’re identical, with one key distinction: ownership. A @StateObject, like @State, is owned by the containing view. An @ObservableObject is often owned external to the view being shown, in the same way a @Binding is. A @StateObject should be used instead of an @ObservedObject when a view owns the observable object local to that view, and when that object needs to survive updates to the view. @StateObject ensures that the observable object is only created once and is not recreated when the view is redrawn. This prevents losing data or causing side effects when the view changes.

An @ObservedObject should be used when the observable object that is being subscribed to is created externally and passed to the view that uses it. @ObservedObject does not guarantee that the observable object will be kept alive by the view, so it should not be used for creating observable objects. Instead, creating the object should be done using @StateObject or @EnvironmentObject.

A common mistake is to use @ObservedObject for creating observable objects inside subviews or conditional views, as they might be recreated when the view is updated. This can cause unexpected behavior or memory leaks. To avoid this, use @StateObject for creating observable objects at the top level of a view hierarchy and pass them down as observed objects or environment objects.

Common Mistakes

Common mistakes made when using @State, @Binding, @StateObject, and @ObservedObject are:

Testability of SwiftUI property wrappers

Not all property wrappers are equally easy to test. Some property wrappers, such as @State and @StateObject, are designed to work with SwiftUI’s lifecycle and environment, which can make them hard to mock or inject in unit tests. Other property wrappers, such as @Binding and @ObservedObject, are more flexible and can be initialized with any values or objects that conform to the required protocols.

Dependency injection is a valid strategy for @Binding and @ObservableObject, but not @State or @StateObject.

A Cheat Sheet

The differences between these property wrappers can take time to internalize and make second nature. Intuitively choosing the right property wrapper for the task at hand is key to preventing bugs.

To that end, if there’s one thing to commit to memory, it’s this:

Property WrapperOwnershipUsageFunctionalityStability
@StateOwns its dataStores simple value type data that is private to a viewCreates a source of truth and updates the view automatically when data changesStable
@StateObjectOwns its dataStores complex reference type data that is created and owned by a viewCreates an observable object and updates any views that observe it when its published properties changeStable
@BindingDoes not own its dataCreates a two-way connection between a property and a source of truth owned by another view or an external sourceReads and writes the property value and reflects any changes in both placesStable
@ObservedObjectDoes not own its dataStores an observable object that is passed into a view by another view or an external sourceUpdates any views that observe it when its published properties changeUnstable