DEV Community

Cover image for Simplifying State Management with @Observable and @ObservedObject
Andy Kolean
Andy Kolean

Posted on

Simplifying State Management with @Observable and @ObservedObject

1. Introduction

State management is a crucial aspect of building responsive and efficient applications. In Swift, @Observable and @ObservedObject are powerful tools that simplify this process, especially when working with SwiftUI. Understanding how to effectively use these property wrappers can help you manage state more efficiently and create more maintainable code.

Importance of Understanding @Observable and @ObservedObject

These property wrappers are fundamental for managing state in SwiftUI. They provide a way to automatically update views when data changes, reducing the need for manual state updates and making your code more reactive and responsive.

Simplification of State Management

Both @Observable and @ObservedObject reduce the boilerplate code typically associated with state management. They allow developers to focus more on the business logic and less on the intricacies of state synchronization. By leveraging these property wrappers, you can write cleaner, more declarative code that is easier to read and maintain.

Overview of What Will Be Covered

In this post, we will explore the basics of @Observable and @ObservedObject, including their definitions, purposes, and how to use them effectively. We will provide practical examples to illustrate their use and compare their functionalities to help you decide when to use each. Finally, we will conclude with a summary and suggest next steps for further learning.


2. The Present: @Observable

@Observable is a property wrapper introduced in Swift 5.9's Observation framework to simplify state management. It allows objects to automatically notify views about state changes, reducing the need for boilerplate code and manual state updates.

Definition and Purpose

@Observable is used to mark a class as observable, meaning any changes to its properties will be automatically published to any observing views. This is particularly useful in SwiftUI, where the UI needs to react to changes in underlying data models. The key advantage of @Observable is that it allows you to write simpler, more declarative code.

How to Use @Observable

To use @Observable, you mark your class with the @Observable attribute and declare your properties as usual. Here's a basic example using a view model:

import SwiftUI
import Observation

@Observable final class UserSettingsViewModel {
    var username: String = "Guest"
    var isLoggedIn: Bool = false
}
Enter fullscreen mode Exit fullscreen mode

With @Observable, any changes to username or isLoggedIn will be automatically detected by any SwiftUI view observing this class.

Example Usage

Here’s how you can use the UserSettingsViewModel class in a SwiftUI view:

struct ContentView: View {
    @Bindable var viewModel = UserSettingsViewModel()

    var body: some View {
        VStack {
            if viewModel.isLoggedIn {
                Text("Welcome, \(viewModel.username)!")
            } else {
                Text("Please log in.")
            }
            Button(action: {
                viewModel.isLoggedIn.toggle()
            }) {
                Text(viewModel.isLoggedIn ? "Log Out" : "Log In")
            }
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

In this example, the ContentView automatically updates when isLoggedIn changes, thanks to the @Observable property wrapper.

Observing State Changes with withObservationTracking

The withObservationTracking function is a powerful tool in the Observation framework that allows you to observe state changes in a more controlled manner. It is particularly useful outside of SwiftUI contexts where automatic observation isn't available.

Non-Recursive Observation

import SwiftUI
import Combine

@Observable final class SettingsViewModel {
    var name: String = "Guest"
}

struct SettingsView: View {
    @Bindable var viewModel = SettingsViewModel()

    func observeChanges() {
        withObservationTracking {
            _ = viewModel.name
        } onChange: {
            print(viewModel.name)
        }
    }

    var body: some View {
        VStack {
            Text("Username: \(viewModel.name)")
            Button(action: {
                viewModel.name = ["Alice", "Bob", "Charlie", "David"].randomElement() ?? "Guest"
            }) {
                Text("Change Username")
            }
        }
        .padding()
        .onAppear {
            observeChanges()
        }
    }
}

struct ContentView: View {
    var body: some View {
        SettingsView()
    }
}
Enter fullscreen mode Exit fullscreen mode

How It Works:

  1. Function Call: The observeChanges function is called when the view appears.
  2. Observation Setup: The function sets up observation on viewModel.name.
  3. Change Detection: When viewModel.name changes, the onChange closure is triggered, printing the new value to the console.
  4. Non-Recursive: This function does not set up continuous observation beyond the initial change detection, meaning it only observes once per view appearance.

In this non-recursive example, changes to viewModel.name are tracked and printed when the view appears and subsequently when the button is pressed to change the name.

Recursive Observation

import SwiftUI
import Combine

@Observable final class SettingsViewModel {
    var name: String = "Guest"
}

struct SettingsView: View {
    @Bindable var viewModel = SettingsViewModel()

    func observeChanges() {
        withObservationTracking {
            _ = viewModel.name
        } onChange: {
            print(viewModel.name)
            Task { @MainActor in
                await observeChanges()
            }
        }
    }

    var body: some View {
        VStack {
            Text("Username: \(viewModel.name)")
            Button(action: {
                viewModel.name = ["Alice", "Bob", "Charlie", "David"].randomElement() ?? "Guest"
            }) {
                Text("Change Username")
            }
        }
        .padding()
        .onAppear {
            observeChanges()
        }
    }
}

struct ContentView: View {
    var body: some View {
        SettingsView()
    }
}
Enter fullscreen mode Exit fullscreen mode

How It Works:

  1. Function Call: The observeChanges function is called when the view appears.
  2. Observation Setup: The function sets up observation on viewModel.name.
  3. Change Detection: When viewModel.name changes, the onChange closure is triggered, printing the new value to the console.
  4. Recursive Call: The onChange closure calls the observeChanges function recursively to ensure continuous observation.

Important Note

The onChange trailing closure is invoked on the leading edge of the mutation, that is, when the mutation is about to happen but hasn’t yet actually happened. Additionally, because it is difficult to predict when the Task closure will be executed, using @MainActor within the Task ensures that the updates occur on the main thread. This prevents the risk of reading an old value of the state.

In this recursive example, the observation restarts every time a change is detected, ensuring continuous monitoring of changes to viewModel.name.

By following this structure, you can effectively use withObservationTracking to observe state changes in a controlled manner. This approach ensures that your code remains efficient and responsive to state updates.

Use Cases:
withObservationTracking is useful for scenarios where you need fine-grained control over state changes, such as logging, analytics, or complex state synchronization tasks outside of SwiftUI.

Using @Bindable for SwiftUI Bindings

SwiftUI introduces the @Bindable property wrapper to create bindings from the properties of any observable type. This wrapper simplifies creating and managing bindings within your SwiftUI views.

@Observable final class AuthViewModel {
    var username = ""
    var password = ""
    var isAuthorized = false

    func authorize() {
        isAuthorized.toggle()
    }
}

struct AuthView: View {
    @Bindable var viewModel: AuthViewModel

    var body: some View {
        VStack {
            if !viewModel.isAuthorized {
                TextField("Username", text: $viewModel.username)
                SecureField("Password", text: $viewModel.password)

                Button("Authorize") {
                    viewModel.authorize()
                }
            } else {
                Text("Hello, \(viewModel.username)")
            }
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

In this example, the @Bindable property wrapper is used to create bindings from the AuthViewModel properties, allowing seamless state management within the SwiftUI view.

Challenges and Best Practices

While @Observable greatly simplifies state management, it also introduces challenges, particularly with nested observable objects. Observing too much state can lead to inefficiencies, while observing too little can cause glitches. SwiftUI's new Observation framework addresses these challenges by ensuring views only observe the state they actually use, and automatically manage subscriptions.

For example, if a view conditionally observes state:

var isDisplayingSecondsElapsed = true

if self.model.isDisplayingSecondsElapsed {


  Text("Seconds elapsed: \(self.model.secondsElapsed)")
}
Toggle(isOn: self.$model.isDisplayingSecondsElapsed) {
  Text("Observe seconds elapsed")
}
Enter fullscreen mode Exit fullscreen mode

Using @Bindable:

struct ObservableCounterView: View {
  @Bindable var model: CounterModel
  // ...
}
Enter fullscreen mode Exit fullscreen mode

The view stops observing secondsElapsed when it’s no longer displayed, preventing unnecessary re-renders.


3. The Past: @ObservedObject

@ObservedObject is a property wrapper used in SwiftUI to observe changes in an observable object. When the observed object changes, the view that uses it automatically updates. While @ObservedObject was extensively used in earlier versions of SwiftUI, the new Observation framework simplifies some of its use cases. However, understanding @ObservedObject is still essential for maintaining compatibility and dealing with legacy code.

Definition and Purpose

@ObservedObject is used to mark a property as an observable object within a SwiftUI view. This means any changes to the properties of the observed object will trigger a re-render of the view. It is commonly used when the view does not own the lifecycle of the observed object but still needs to react to its changes.

How to Use @ObservedObject

To use @ObservedObject, you typically define a class conforming to the ObservableObject protocol, and then use @ObservedObject to mark the property in your view.

import SwiftUI
import Combine

class UserSettings: ObservableObject {
    @Published var username: String = "Guest"
    @Published var isLoggedIn: Bool = false
}

struct ContentView: View {
    @ObservedObject var settings = UserSettings()

    var body: some View {
        VStack {
            if settings.isLoggedIn {
                Text("Welcome, \(settings.username)!")
            } else {
                Text("Please log in.")
            }
            Button(action: {
                settings.isLoggedIn.toggle()
            }) {
                Text(settings.isLoggedIn ? "Log Out" : "Log In")
            }
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

In this example, the ContentView automatically updates when isLoggedIn changes, thanks to the @ObservedObject property wrapper.

Example Usage in a Real-World Scenario

Consider a more complex scenario where @ObservedObject is used in a multi-view application.

class Task: ObservableObject {
    @Published var title: String
    @Published var isCompleted: Bool

    init(title: String, isCompleted: Bool = false) {
        self.title = title
        self.isCompleted = isCompleted
    }
}

struct TaskListView: View {
    @ObservedObject var task: Task

    var body: some View {
        HStack {
            Text(task.title)
            Spacer()
            Button(action: {
                task.isCompleted.toggle()
            }) {
                Image(systemName: task.isCompleted ? "checkmark.square" : "square")
            }
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Here, the TaskListView updates when the task's isCompleted status changes.

Differences from @Observable

While @ObservedObject and @Observable both serve to notify views of changes, they have different use cases and benefits:

  • @Observable simplifies the code by eliminating the need for manual @Published properties and ObservableObject conformance.
  • @Observable integrates seamlessly with SwiftUI and other Swift data structures, making it more flexible and less verbose.
  • @ObservedObject requires explicit declaration of @Published properties and conformance to ObservableObject.

When to Use Each

Use @Observable for new code and when you want to take advantage of the streamlined syntax and automatic observation. Use @ObservedObject for existing codebases that already use ObservableObject and @Published properties, or when you need finer control over the published properties.

Migration from @ObservedObject to @Observable

To migrate from @ObservedObject to @Observable, follow these steps:

  1. Remove ObservableObject conformance from your class.
  2. Remove @Published property wrappers.
  3. Add the @Observable macro to the class definition.
  4. Replace @ObservedObject in your views with @Bindable where necessary.

Example:

Before Migration:

class UserSettings: ObservableObject {
    @Published var username: String = "Guest"
    @Published var isLoggedIn: Bool = false
}

struct ContentView: View {
    @ObservedObject var settings = UserSettings()

    var body: some View {
        VStack {
            if settings.isLoggedIn {
                Text("Welcome, \(settings.username)!")
            } else {
                Text("Please log in.")
            }
            Button(action: {
                settings.isLoggedIn.toggle()
            }) {
                Text(settings.isLoggedIn ? "Log Out" : "Log In")
            }
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

After Migration:

@Observable class UserSettings {
    var username: String = "Guest"
    var isLoggedIn: Bool = false
}

struct ContentView: View {
    @Bindable var settings = UserSettings()

    var body: some View {
        VStack {
            if settings.isLoggedIn {
                Text("Welcome, \(settings.username)!")
            } else {
                Text("Please log in.")
            }
            Button(action: {
                settings.isLoggedIn.toggle()
            }) {
                Text(settings.isLoggedIn ? "Log Out" : "Log In")
            }
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

4. Comparison

In this section, we will compare @Observable and @ObservedObject to highlight their differences, use cases, and benefits. Understanding when to use each will help you make informed decisions in your SwiftUI projects.

Differences Between @Observable and @ObservedObject

  1. Declaration and Usage:

    • @Observable: Simplifies the code by eliminating the need for ObservableObject conformance and @Published properties. It uses the @Observable macro to make a class observable.
    • @ObservedObject: Requires explicit conformance to ObservableObject and the use of @Published for each observable property.
  2. Property Wrappers:

    • @Observable: Works seamlessly with the @Bindable property wrapper, allowing easy binding creation within views.
    • @ObservedObject: Uses @Published to mark observable properties and @ObservedObject to mark the observing properties within views.
  3. Ease of Use:

    • @Observable: Offers a more declarative approach, reducing boilerplate and making the code easier to read and maintain.
    • @ObservedObject: Requires more boilerplate code, making it slightly more complex to manage.
  4. Integration with SwiftUI:

    • @Observable: Automatically integrates with SwiftUI, making it easier to use within SwiftUI views.
    • @ObservedObject: Works well with SwiftUI but requires manual setup of @Published properties and conformance to ObservableObject.
  5. Platform Availability:

    • @Observable: Available only in iOS 17 and later, as well as the corresponding versions of macOS, tvOS, and watchOS.
    • @ObservedObject: Available in earlier versions of iOS and SwiftUI, providing broader compatibility for older devices and projects.

When to Use Each

  • Use @Observable:

    • For new codebases where you want to take advantage of the streamlined syntax and automatic observation.
    • When you need a simpler, more declarative approach to state management.
    • In scenarios where you want to avoid the manual boilerplate code associated with @ObservedObject and @Published.
    • When targeting iOS 17 and later, and the corresponding versions of other Apple platforms.
  • Use @ObservedObject:

    • In existing codebases that already use ObservableObject and @Published.
    • When you need finer control over which properties are published and observed.
    • For compatibility with older versions of SwiftUI and iOS prior to Swift 5.9.

Practical Example and Comparison

Consider the following practical example to illustrate the differences:

Using @ObservedObject:

import SwiftUI
import Combine

class Task: ObservableObject {
    @Published var title: String
    @Published var isCompleted: Bool

    init(title: String, isCompleted: Bool = false) {
        self.title = title
        self.isCompleted = isCompleted
    }
}

struct TaskListView: View {
    @ObservedObject var task: Task

    var body: some View {
        HStack {
            Text(task.title)
            Spacer()
            Button(action: {
                task.isCompleted.toggle()
            }) {
                Image(systemName: task.isCompleted ? "checkmark.square" : "square")
            }
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Using @Observable:

import SwiftUI
import Observation

@Observable final class Task {
    var title: String
    var isCompleted: Bool

    init(title: String, isCompleted: Bool = false) {
        self.title = title
        self.isCompleted = isCompleted
    }
}

struct TaskListView: View {
    @Bindable var task = Task(title: "Sample Task")

    var body: some View {
        HStack {
            Text(task.title)
            Spacer()
            Button(action: {
                task.isCompleted.toggle()
            }) {
                Image(systemName: task.isCompleted ? "checkmark.square

" : "square")
            }
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Performance Considerations

  • @Observable: Efficiently tracks and observes only the properties used in the view, reducing unnecessary updates and improving performance.
  • @ObservedObject: Observes all @Published properties, which can lead to inefficiencies if not managed properly.

Best Practices

  • @Observable:

    • Use for new projects to leverage the latest advancements in the Observation framework.
    • Take advantage of the automatic integration with SwiftUI and simplified syntax.
    • Ensure proper handling of nested observable objects to avoid performance pitfalls.
    • Be mindful of the iOS 17+ requirement and ensure your project targets the appropriate platform versions.
  • @ObservedObject:

    • Use in existing projects or when migrating from older SwiftUI codebases.
    • Maintain clear separation of concerns by using @Published properties judiciously.
    • Ensure compatibility with older SwiftUI and iOS versions when needed.

Conclusion

In this post, we explored the fundamentals of @Observable and @ObservedObject in Swift, understanding their purposes, usage, and differences. These property wrappers are essential tools for managing state in SwiftUI applications, each offering unique benefits and considerations.

Recap of Key Points

  • @Observable: Introduced in Swift 5.9 and available in iOS 17+, this property wrapper simplifies state management by eliminating the need for ObservableObject conformance and @Published properties. It integrates seamlessly with SwiftUI and reduces boilerplate code, making it ideal for new projects targeting the latest platforms.
  • @ObservedObject: A well-established property wrapper that requires explicit conformance to ObservableObject and the use of @Published properties. It is suitable for existing projects and those needing compatibility with older SwiftUI and iOS versions.

Benefits of Adopting @Observable

  • Reduced Boilerplate: @Observable minimizes the need for repetitive code, making your SwiftUI views cleaner and more maintainable.
  • Automatic Integration: With automatic observation handling, @Observable ensures your views update correctly without manual intervention.
  • Enhanced Performance: By observing only the properties used in the view, @Observable reduces unnecessary updates, improving overall performance.

When to Use Each Tool

  • Use @Observable for new projects, especially when targeting iOS 17+ and leveraging the latest Swift features.
  • Use @ObservedObject for existing projects, ensuring compatibility with older platforms and finer control over published properties.

References and Further Reading

Next Post

In the next post, we will delve deeper into the practical applications of @Perceptible from the Perception library. We will explore how this annotation simplifies state management on older iOS versions with detailed examples and best practices for leveraging its full potential in SwiftUI applications.

Top comments (0)