Reactivity in SwiftUI: @ObservedObject and @StateObject for Dynamic UIs

Nicolle Policiano
Policiano
Published in
6 min readApr 5, 2024
Photo by Milad Fakurian on Unsplash

Introduction

SwiftUI stands as a reactive framework, and leverages two helpful tools called ObservedObject and StateObject for creating user interfaces that are not only dynamic but inherently responsive to data changes. In this article, we'll explore what ObservedObject and StateObject are, how they work with the ObservableObject protocol, and how they make our SwiftUI views dynamic and reactive to data changes.

Understanding the ObservableObject Protocol

In SwiftUI’s reactive framework, is advisable to consider the principle of separation of concerns in order to maintain clean views focused on UI elements. Take a look at the following example that shows a simple view with no separation of concern:

struct BookView: View {
@State var title = ""
@State var isRead = false

private let networking = Networking()

var body: some View {
VStack {
Text(title)
Text(isRead ? "Read" : "Not read")
}
.task {
do {
let book = try await networking.fetchBook()
title = book.title
isRead = book.isRead
} catch {
// Error handling
}
}
}
}

class Networking {
func fetchBook() async throws -> Book {
// Define network request
}
}

The BookView is taking too much responsibility there. Besides presenting the book data, it's also fetching and processing the data asynchronously. Given the challenges of unit testing SwiftUI views, we had better isolate the data processing somewhere else and make the View leaner.

Utilizing a view model helps isolate logic and network requests from UI code. A view model typically employs the @Published property wrapper in conjunction with the ObservableObject protocol, allowing SwiftUI views to react to changes in the view model. This mechanism ensures that updates within the view model result in automatic UI refreshes, keeping the user interface in sync with the new data.

The ObservableObject protocol on a view model acts as a messenger. It requires a@Published property wrapper to notify the views of data changes. The published is updated on the main thread as views observe it, making it possible to integrate view model and subscribed views.

Let’s take this BookViewModel class as an example:

class BookViewModel: ObservableObject {
@Published var title = ""
@Published var isRead = false

private let networking = Networking()

func onAppear() {
Task {
do {
let book = try await networking.fetchBook()
await MainActor.run {
self.title = book.title
self.isRead = book.isRead
}
} catch {
// Error handling
}
}
}
}

This example demonstrates an asynchronous network request through fetchBook(), followed by updating the title and isRead on the main thread. The @Published property wrapper sends updates whenever those published properties change, automatically triggering view updates as new data is fetched. Now that we have moved a bunch of logic to a View Model, we need to connect it with the View.

Subscribing to Updates with ObservedObject and StateObject

SwiftUI leverages ObservedObject and StateObject property wrappers to connect views to their data sources. These property wrappers enable views to listen for updates from ObservableObject instances, allowing immediate UI updates upon data changes. When the data changes within an ObservableObject, it triggers notifications to views that employ either ObservedObject or StateObject. These views listen for these updates, redrawn, and update themselves to reflect the new state. The refactored view using the view model will look like this:

struct BookView: View {
@StateObject var viewModel = BookViewModel()

var body: some View {
VStack {
Text(viewModel.title)
Text(viewModel.isRead ? "Read" : "Not read")
}
.onAppear {
viewModel.onAppear()
}
}
}

To effectively manage and present this dynamic data, the SwiftUI View needs to be reactive to changes in the view model. This requires using either ObservedObject or StateObject within the view to observe properties marked with @Published in the view model. This setup ensures that the view automatically reflects updates to its data.

This is an example using the ObservedObject property wrapper. It looks very similar to the example above.

struct BookView: View {
@ObservedObject var viewModel = BookViewModel()

// ...
}

They look very similar in syntax, but what are the effective differences? When do we use each one?

StateObject vs ObservedObject

Both StateObject and ObservedObject are property wrappers used in SwiftUI to allow a view to observe and respond to changes in a view model's data. The choice between the two depends on how you want to manage the lifecycle of your data in relation to the view's re-creation.

ObservedObject:

  • Ideal for data models that exist outside the view and whose changes need to be monitored by the view. However, careful management is necessary to avoid data resets on view redraws, especially in scenarios where maintaining the state of the data across view updates is required.
  • When the parent view redraws, it can re-instantiate all other child views. In this process, any ObservedObject directly created within a view will be recreated, leading to a data reset. Following the natural behavior of object-oriented programming, the view model will also be re-instantiated, resulting in a reset of the data, reverting to the initial presentation of the view model. This can be problematic, as the data returns to its initial state.
  • We need to be careful when initializing the ObservedObject directly in the view in order to avoid data loss after the view's redraw. To use the ObservedObjectsafely, we should inject it into the view, instead of instantiating it directly within the view, ensuring that the object maintains its state after the view's redraw.

A classic example is a CounterView that increments count by 1 each time it is tapped, dynamically reacting to changes in a shared Store marked as @ObservedObject.

The ContentView integrates the CounterView passing store via init to maintain a shared state after the view redraws, and also tracks a local @State redrawCount for additional display demonstrating every redrawn in the view.

class Store: ObservableObject {
@Published var count = 0
}

struct CounterView: View {
@ObservedObject var store: Store

var body: some View {
Text("Count: \(store.count)")
.onTapGesture {
store.count += 1
}
}
}

struct ContentView: View {
@State var redrawCount = 0
@ObservedObject var store = Store()

var body: some View {
VStack {
Text("Taps: \(redrawCount)")
.onTapGesture {
redrawCount += 1
}
CounterView(store: store)
}
.padding()
}
}

The following example demonstrates the Store being initialized directly inside the CounterView, leading to a reset of the count value every time the view redraws. This happens as each view instantiates its own Store as anObservedObject instead of reusing a shared instance.

struct CounterView: View {
@ObservedObject var store: Store()

var body: some View {
Text("Count: \(store.count)")
.onTapGesture {
store.count += 1
}
}
}

struct ContentView: View {
@State var redrawCount = 0
@ObservedObject var store = Store()

var body: some View {
VStack {
Text("Taps: \(redrawCount)")
.onTapGesture {
redrawCount += 1
}
CounterView()
}
.padding()
}
}

StateObject:

  • The StateObject is used to store data within the view itself. It is designed to maintain the persistence of the data even when the view is redrawn, unlike the ObservedObject.
  • Unlike ObservedObject, a StateObject is not recreated during view redraws. If a parent view is redrawn, SwiftUI maintains the instance of the data model marked with StateObject and reinjects it into the recreated view. This preserves the data model's state through redraws, avoiding the loss of data changes.
  • By using StateObject, you ensure that the state of your data is maintained without the need to manage dependency injection manually, as is recommended with ObservedObject to avoid data resets.

The following example demonstrates the implementation of the @StateObject property wrapper:

struct CounterView: View {
@StateObject var store = Store()

var body: some View {
Text("Count: \(store.count)")
.onTapGesture {
store.count += 1
}
}
}

struct ContentView: View {
@State var redrawCount = 0
@StateObject var store = Store()

var body: some View {
VStack {
Text("Taps: \(redrawCount)")
.onTapGesture {
redrawCount += 1
}
CounterView()
}
.padding()
}
}

Conclusion

In SwiftUI, ObservedObject and StateObject property wrappers, along with the ObservableObject protocol, play crucial roles in creating dynamic and responsive user interfaces. These tools allow UI components to automatically update in response to data changes. ObservedObject is used for observing external data models and requires careful management to prevent data loss on view redraws. StateObject is used for data that is integral to the view, ensuring data persistence through redraws without the need for manual dependency injection.

If you want to get more details about this topic, I have a great article from SwiftLee that helped me a lot on understanding it.

--

--