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:
- The data is simple (think
enum
s,String
,Int
, etc.) - The data is only used by one view or its subviews.
- The data does not depend on any external sources.
@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 class
es), 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:
- The data is owned by another view or an external source of truth.
- The data needs to be passed down to a child view or a subview.
- The data needs to be modified by both the parent view and the child view.
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 — enum
s, Bool
s, Int
s, 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:
- The data is passed into the view by another view or an external source of truth.
- The data needs to be observed by multiple views or across the app.
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:
- Using
@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. Instead, use@StateObject
for creating observable objects at the top level of a view hierarchy and pass them down as observed objects or environment objects. - Using
@State
for complex or reference types, as they might cause unexpected behavior or memory leaks. Instead, use@StateObject
for reference types that conform to ObservableObject and are owned by a view. - Attaching property observers to property wrappers. For example, using
didSet
on a@State
property can cause an infinite loop of updates. Instead, useonChange()
modifier to perform actions when a property changes.
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 Wrapper | Ownership | Usage | Functionality | Stability |
---|---|---|---|---|
@State | Owns its data | Stores simple value type data that is private to a view | Creates a source of truth and updates the view automatically when data changes | Stable |
@StateObject | Owns its data | Stores complex reference type data that is created and owned by a view | Creates an observable object and updates any views that observe it when its published properties change | Stable |
@Binding | Does not own its data | Creates a two-way connection between a property and a source of truth owned by another view or an external source | Reads and writes the property value and reflects any changes in both places | Stable |
@ObservedObject | Does not own its data | Stores an observable object that is passed into a view by another view or an external source | Updates any views that observe it when its published properties change | Unstable |