SwiftUI, Easily to Understand: ObservedObject vs StateObject with Code Example

Luthfi Abdur Rahim
4 min readSep 12, 2024

--

In some simple cases, the behavior between @StateObject and @ObservedObject might not seem different, especially if the app doesn't trigger SwiftUI to re-render a Screen by removing it from the hierarchy.

To demonstrate the real difference between @StateObject and @ObservedObject, we need to introduce a scenario where Screen1 is removed from the view hierarchy and then re-created. This is when the issue with @ObservedObject will become apparent, as it will cause the viewModel to be reinitialized, unlike @StateObject.

Example: Triggering the Difference Between @StateObject and @ObservedObject

The key difference is seen when the view is removed from the hierarchy and reinserted (such as by navigating away from the view and then back to it). This behavior forces SwiftUI to recreate the view, which exposes the problem with @ObservedObject to initialized it inside the View.

Inside the Code Example:

  1. I’ve added a toggle button to show or hide Screen1, which will simulate removing the view from the hierarchy.
  2. The wrong behavior occurs when using @ObservedObject because ContentViewModel is reinitialized each time Screen1 is re-added, which leads to a reset of the count.

Here’s the complete code showing the difference between @StateObject and @ObservedObject:

With @ObservedObject (Wrong Implementation):

import SwiftUI

class ContentViewModel: ObservableObject {
@Published var count = 0

func increment() {
count += 1
}
}
struct Screen1: View {
@ObservedObject var viewModel = ContentViewModel() // Wrong: Reinitializes every time
var body: some View {
VStack {
Text("Screen 1 - Count: \(viewModel.count)")
.padding()
Button("Increment") {
viewModel.increment()
}
.padding()
NavigationLink(destination: Screen2(viewModel: viewModel)) {
Text("Go to Screen 2")
}
.padding()
}
}
}
struct Screen2: View {
@ObservedObject var viewModel: ContentViewModel
var body: some View {
VStack {
Text("Screen 2 - Count: \(viewModel.count)")
.padding()
Button("Increment") {
viewModel.increment()
}
.padding()
}
}
}
struct ContentView: View {
@State private var showScreen1 = true
var body: some View {
VStack {
if showScreen1 {
Screen1() // Recreates Screen1 each time this is toggled
}
Button("Toggle Screen 1") {
showScreen1.toggle()
}
.padding()
}
}
}
@main
struct MyApp: App {
var body: some Scene {
WindowGroup {
ContentView()
}
}
}

Steps to Test:

  1. Run the app.
  2. On Screen1, press the Increment button a few times to increase the count.
  3. Press the Toggle Screen 1 button to hide and then show Screen1 again.
  4. Notice that the counter resets to 0 each time you toggle the view. This happens because @ObservedObject reinitializes ContentViewModel each time Screen1 is recreated, causing data loss.

With @StateObject (Correct Implementation):

import SwiftUI

class ContentViewModel: ObservableObject {
@Published var count = 0
func increment() {
count += 1
}
}
struct Screen1: View {
@StateObject var viewModel = ContentViewModel() // Correct: Retains state across view updates
var body: some View {
VStack {
Text("Screen 1 - Count: \(viewModel.count)")
.padding()
Button("Increment") {
viewModel.increment()
}
.padding()
NavigationLink(destination: Screen2(viewModel: viewModel)) {
Text("Go to Screen 2")
}
.padding()
}
}
}
struct Screen2: View {
@ObservedObject var viewModel: ContentViewModel
var body: some View {
VStack {
Text("Screen 2 - Count: \(viewModel.count)")
.padding()
Button("Increment") {
viewModel.increment()
}
.padding()
}
}
}
struct ContentView: View {
@State private var showScreen1 = true
var body: some View {
VStack {
if showScreen1 {
Screen1() // Screen1 is only initialized once
}
Button("Toggle Screen 1") {
showScreen1.toggle()
}
.padding()
}
}
}
@main
struct MyApp: App {
var body: some Scene {
WindowGroup {
ContentView()
}
}
}

Steps to Test:

  1. Run the app with the corrected code (@StateObject in Screen1).
  2. On Screen1, press the Increment button a few times to increase the count.
  3. Press the Toggle Screen 1 button to hide and then show Screen1 again.
  4. Notice that the counter retains its value and does not reset to 0. This is because @StateObject ensures that the ContentViewModel is initialized only once, and its state is preserved across view updates.

Explanation of the Difference:

  • With @ObservedObject: Each time Screen1 is recreated (toggled in or out of the view hierarchy), the viewModel is reinitialized, causing the counter to reset to 0.
  • With @StateObject: The viewModel is only created once and persists across view updates, so the counter retains its value even when the view is removed and reinserted into the hierarchy.

This test should clearly show you the difference in behavior between @ObservedObject and @StateObject when the view is removed and re-added to the view hierarchy.

Summary of Best Practices:

  • Use @StateObject when the view creates and owns the object and is responsible for maintaining its lifecycle across view updates.
  • Use @ObservedObject when the view is observing an object passed from elsewhere, without taking ownership or responsibility for its lifecycle.
  • Don’t use @StateObject in child views where the object is passed in from a parent. Use @ObservedObject instead.
  • You should not always use @StateObject for all use cases, as it is meant for managing the lifecycle of the object, and doing so inappropriately can lead to inefficiencies or incorrect behavior.

--

--

No responses yet