Exploring Key Property Wrappers in SwiftUI: @State, @Binding, @StateObject, @ObservedObject, @EnvironmentObject, and @Environment

Published on

Get weekly handpicked updates on Swift and SwiftUI!

In this article, we will explore several property wrappers that are frequently used and crucial in SwiftUI development. This article aims to provide an overview of the main functions and usage considerations of these property wrappers, rather than a detailed usage guide.

This article is written at the request of several friends, intending to help developers who are familiar with general programming but relatively new to SwiftUI, to quickly understand the core functions and applicable scenarios of these property wrappers.

1 @State

@State is one of the most commonly used property wrappers in SwiftUI, primarily used for managing private data within a view. It’s particularly suited for storing value-type data such as strings, integers, enums, or struct instances.

  • @State is used to manage a view’s private state.
  • It’s mainly used for storing value-type data (lifespan consistent with the view).

1.1 Typical Use Cases

  • @State is the ideal choice when a view update is triggered by changes in data within the view.
  • It’s commonly used for simple UI component state management, such as switch states, text input, etc.
  • If data does not require complex cross-view sharing, @State can simplify state management.

1.2 Considerations

  • Try to use @State only within the view, and consider it as a private property of the view, even if not explicitly marked as private.

  • @State provides a two-way data binding pipeline for wrapped data, accessible using the $ prefix.

  • @State is not suitable for storing large amounts of data or complex data models; in these cases, @StateObject or other state management solutions are more appropriate.

  • Property wrappers are essentially structs. The @ prefix is used to wrap other data; without @, it represents its own type. For more details, refer to John Sundell and Antoine van der Lee, or read @State Research in SwiftUI.

  • When assigning values in the constructor, access the raw value of @State using an underscore _ for assignment.

Swift
@State var name: String
init(text: String) {
    // Assign to the underscore version, wrapping it with the State type itself
    _name = State(wrappedValue: text)
}
  • @State variables can only be assigned once in the view’s constructor, and subsequent adjustments should be made within the view’s body. See How to Avoid Repeating SwiftUI View Updates.

  • If there’s no need to modify the value in the current view or in child views (through @Binding), there’s no need to use @State.

  • In some cases, @State is also used to store non-value types, such as reference types, to ensure their uniqueness and lifespan.

Swift
@State var textField: UITextField?
TextField("", text: $text)
    .introspect(.textField, on: .iOS(.v17)) {
        // Holding the UITextField instance
        self.textField = $0
    }
Swift
@State var text: String = ""
Button("Change") {
    // No need to switch back to the main thread
    Task.detached {
        text = "hi"
    }
}

2 @Binding

@Binding is a property wrapper in SwiftUI used for implementing two-way data binding. It creates a two-way connection between a value (like a Bool) and the UI elements that display and modify these values.

  • @Binding does not hold the data directly but provides a wrapper for read and write access to other data sources.
  • It allows UI elements to directly modify the data and reflect these changes.

2.1 Typical Use Cases

  • @Binding is mainly used with UI components that support two-way data binding, such as in combination with TextField, Stepper, Sheet, and Slider.
  • It is suitable for situations where you need to directly modify the data in a parent view from a child view.

2.2 Considerations

  • Use @Binding cautiously; it’s unnecessary if a child view only needs to respond to data changes without modifying them.

  • In complex view hierarchies, passing @Binding through multiple levels can make data flow hard to track, in which case other state management methods should be considered.

  • Ensure the data source for @Binding is reliable, as an incorrect data source can lead to inconsistencies or application crashes. Since @Binding is just a conduit, it doesn’t guarantee that the corresponding data source will exist when called.

  • Developers can customize Binding by providing get and set methods.

Swift
let binding = Binding<String>(
    get: { text },
    // Limit the length of the string
    set: { text = String($0.prefix(10)) }
)
  • Creating extensions for Binding type can greatly enhance development efficiency and flexibility. For more, read: SwiftUI Binding Extensions.
Swift
// Convert a Binding<V?> to a Binding<Bool>
extension Binding {
    static func isPresented<V>(_ value: Binding<V?>) -> Binding<Bool> {
        Binding<Bool>(
            get: { value.wrappedValue != nil },
            set: {
                if !$0 { value.wrappedValue = nil }
            }
        )
    }
}
  • In the Observation framework, @Bindable can be used to create a corresponding Binding interface for @Observable instances. For details, see A Deep Dive Into Observation: A New Way to Boost SwiftUI Performance.

  • When declaring constructor parameters, the wrapped value type of Binding (return type of the get method) needs to be explicitly specified, like Binding<String>.

  • @Binding is not an independent data source. It’s merely a reference to already existing data. The view is only updated when the get method reads values that can trigger a view update (like @State, @StateObject), which is crucial for custom Binding.

Swift
struct Test: View {
    let a = A()
    var body: some View {
        let binding = Binding<String>(
            get: { a.name },
            set: { a.name = $0 }
        )
        // Although A conforms to the ObservableObject protocol, since it's not associated with the view using StateObject, the Binding created for its properties will also not trigger a view update
        Text(binding.wrappedValue)
        TextField("input:", text: binding)
    }

    class A: ObservableObject {
        @Published var name: String = ""
    }
}

3 @StateObject

@StateObject is a property wrapper in SwiftUI for managing instances of objects conforming to the ObservableObject protocol. It ensures these instances have a lifecycle that is at least as long as the current view’s lifecycle.

  • @StateObject is specifically used for managing instances that conform to the ObservableObject protocol.
  • The annotated object instance remains unique throughout the entire lifecycle of the view, meaning it won’t be recreated even if the view updates.

3.1 Typical Use Cases

  • @StateObject is typically used at the top of the view hierarchy to create and maintain ObservableObject instances.
  • It’s commonly used for data models or business logic that need to persist throughout the entire lifecycle of the view.
  • Compared to @State, @StateObject is more suitable for managing complex data models and their associated logic.

3.2 Considerations

  • The conditions triggering a view update with @StateObject include assigning values to properties marked with @Published (regardless of whether the new value is different from the old) and invoking the objectWillChange publisher.

  • Use @StateObject only in views that must respond to changes in instance properties. If you only need to read data without observing changes, consider other options.

  • Introducing @StateObject implies that all related operations occur on the main thread (as SwiftUI implicitly adds @MainActor to the view), including asynchronous operations. Code that needs to run on a non-main thread should be separated from the view code.

Swift
struct B: View {
    // Using StateObject is equivalent to adding @MainActor to the current view
    @StateObject var store = Store()
    var body: some View {
        Button("Main Thread") {
            Task.detached {
                await printThreadName()
                // output <_NSMainThread: 0x60000170c000>{number = 1, name = main}
            }
        }
    }
    
    func printThreadName() async {
        print(Thread.current)
    }
}
  • If an instance is created in a context where the view’s lifespan is guaranteed (such as at the app level), and there is no need to respond to changes in that instance’s properties at the current level, @StateObject may not be necessary.
Swift
struct DemoApp: App {
    // Since the lifespan of the view at this level is consistent with the application, and if there's no need to respond to changes in 'store', StateObject is not required
    let store = Store()

    var body: some Scene {
        WindowGroup {
            Test()
                .environmentObject(store)
        }
    }
}

4 @ObservedObject

@ObservedObject is a property wrapper in SwiftUI used to create a connection between a view and instances of ObservableObject, mainly for introducing external ObservableObject instances during the view’s lifespan.

  • @ObservedObject does not own the observed instance and does not guarantee its lifespan.
  • @ObservedObject can switch its associated instance during the view’s lifespan.

4.1 Typical Use Cases

  • Often used in conjunction with @StateObject, where a parent view creates an instance using @StateObject, and a child view introduces this instance through @ObservedObject, responding to changes in the instance.
  • Suitable for scenarios where dynamic switching of instances is needed. For example, in a NavigationSplitView, selecting different instances in the sidebar dynamically changes the data source in the detail view. For more details, please read StateObject and ObservedObject.
Swift
// Define a data model conforming to the ObservableObject protocol
class DataModel: ObservableObject, Identifiable {
    let id = UUID()
}

struct MyView: View {
    @State private var items = [DataModel(), DataModel()]

    var body: some View {
        VStack {
            // Switch the DataModel instance associated with MySubView
            Button("Replace Model") {
                items.reverse()
            }
            MySubView(model: items.first!)
        }
    }
}

// Subview
struct MySubView: View {
    // Introduce an external ObservableObject instance with @ObservedObject
    @ObservedObject var model: DataModel 

    var body: some View {
        VStack {
            // Display the UUID of the current DataModel instance
            // When the 'items' array in MyView changes, the displayed UUID here will update, showcasing the dynamic switching capability of @ObservedObject
            Text(model.id.uuidString)
        }
    }
}
  • Used in views to introduce ObservableObject instances whose lifespans are ensured by external frameworks or code, such as introducing NSManagedObject instances from Core Data.

4.2 Considerations

  • In iOS 13, due to the absence of @StateObject, @ObservedObject was the only choice, which could lead to unexpected results due to the inability to guarantee the lifespan of the instance. To avoid such issues, one could hold the instance using @State in a higher-level view (where stability isn’t a concern), and then introduce it in the view where it’s used via @ObservedObject.
  • When introducing instances of ObservableObject provided by third parties, it’s crucial to ensure that the object referenced by @ObservedObject is available throughout the entire lifespan of the view. Otherwise, it might lead to runtime errors.

5 @EnvironmentObject

@EnvironmentObject is a property wrapper used in SwiftUI to create a connection between the current view and an ObservableObject instance passed down through the environment from a higher-level view. It provides a convenient way to introduce shared data across different view hierarchies without explicitly passing it through each view’s constructor.

5.1 Typical Use Cases

  • Ideal for sharing the same data model across multiple views, such as user settings, themes, or application states.
  • Suitable for building complex view hierarchies where multiple views need access to the same ObservableObject instance.

5.2 Considerations

  • Before using @EnvironmentObject, ensure that the corresponding instance has been provided upstream in the view hierarchy (using the .environmentObject modifier). Failure to do so will result in a runtime error.
  • The conditions that trigger view updates for @EnvironmentObject are the same as those for @StateObject and @ObservedObject.
  • Like @ObservedObject, @EnvironmentObject supports dynamically switching the associated instance.
Swift
struct MyView: View {
    @State private var items = [DataModel(), DataModel()]
    var body: some View {
        VStack {
            Button("Replace Model") {
                // Switch the instance associated with the child view MySubView
                items.reverse()
            }
            MySubView()
                .environmentObject(items.first!)
        }
    }
}

struct MySubView: View {
    @EnvironmentObject var model: DataModel // Dynamically switch associated instance
    var body: some View {
        VStack {
            Text(model.id.uuidString)
        }
    }
}
  • Only introduce @EnvironmentObject when necessary, as it can trigger unnecessary view updates. Often, multiple views from different levels observe and respond to the same instance, and proper optimization is required to avoid performance degradation in the application. This is a reason why many developers are wary of @EnvironmentObject.
  • In a view hierarchy, only one instance of the same type of environment object is effective.
Swift
@StateObject var a = DataModel()
@StateObject var b = DataModel()

MySubView()
    .environmentObject(a) // The one closer to the view is effective
    .environmentObject(b)

6 @Environment

@Environment is a property wrapper used by views to read, respond to, and invoke specific values from the environment. It allows views to access data, instances, or methods provided by SwiftUI or the app environment.

6.1 Typical Use Cases

  • When needing to access and respond to environment values provided by the system or higher-level views, such as interface style (dark/light mode), device orientation, or font size (typically corresponding to value types).
  • When accessing and invoking SwiftUI’s ModelContext (corresponding to reference types).
  • When using methods provided by the system, such as dismiss or openURL (encapsulated via the struct’s callAsFunction method).

6.2 Considerations

  • Compared to the complex logic handled by instances provided by @EnvironmentObject, data introduced by @Environment typically has more specific functionality.
  • Developers can create custom environment values by defining a custom EnvironmentKey. Like system-provided environment values, they can define various types (value types, Bindings, reference types, methods). For more details, see Custom SwiftUI Environment Values Cheatsheet.
Swift
public struct ContainerEnvironmentKey: EnvironmentKey {
    // Default value for the example environment key
    public static var defaultValue = ContainerEnvironment(containerName: "Default")
}

public extension EnvironmentValues {
    var overlayContainer: ContainerEnvironment {
        get { self[ContainerEnvironmentKey.self] }
        set { self[ContainerEnvironmentKey.self] = newValue }
    }
}
  • In SwiftUI, the definition style similar to EnvironmentKey is used in many ways. Once mastered, it’s easy to grasp others, such as PreferenceKey (for child-to-parent view communication), FocusedValueKey (for values based on focus), and LayoutValueKey (for child-to-layout container communication).
  • Due to the presence of default values, @Environment won’t cause an app crash due to missing values, but this can also lead to developers forgetting to inject values.
  • Unlike @EnvironmentObject, lower-level views cannot modify the EnvironmentValue values passed down from ancestor views.
  • Multiple properties of the same type but with different names can be created in EnvironmentValue by defining different EnvironmentKeys.

Summary

  • @StateObject, @ObservedObject, and @EnvironmentObject are specifically used for associating instances that conform to the ObservableObject protocol.
  • While @StateObject can sometimes replace @ObservedObject and offer similar functionality, they each have unique use cases. @StateObject is typically used for creating and maintaining instances, whereas @ObservedObject is for introducing and responding to already existing instances.
  • In environments with iOS 17+ where applications primarily rely on the Observation and SwiftData frameworks, the use of these three property wrappers might be relatively less frequent.
  • @State and @Environment are not limited to storing value types but can also be used for other types.
  • @Environment provides a relatively safer method to introduce environmental data because it can offer default values through EnvironmentValue. This reduces the risk of application crashes due to missing data injections.
  • In the context of the Observation framework, @State and @Environment become the primary property wrappers. Whether it’s value types or @Observable instances, both can be introduced into views through these wrappers.
  • Custom Bindings offer great flexibility, allowing developers to implement complex logic between data sources and UI components that depend on Bindings with concise code.

Each property wrapper has its unique use cases and advantages. Choosing the right tool is crucial for building efficient and maintainable SwiftUI applications. As often mentioned in software development, no single tool is a panacea, but using them appropriately can greatly enhance our development efficiency and application quality.

Weekly Swift & SwiftUI insights, delivered every Monday night. Join developers worldwide.
Easy unsubscribe, zero spam guaranteed