Skip to main content

SwiftUI View Not Updating? Why You Should Stop Using @ObservedObject for Defaults

 You have built a standard MVVM (Model-View-ViewModel) feature in SwiftUI. Your logic is sound, your logic inside the ObservableObject prints the correct values to the console, but the UI refuses to reflect those changes. Or perhaps even worse: the UI updates for a split second, then snaps back to its initial state, erasing user input.

This behavior is rarely a bug in your business logic. It is almost always a misunderstanding of the SwiftUI View Lifecycle and memory ownership.

If you are initializing a ViewModel directly inside a View using @ObservedObject var model = MyViewModel(), you are inadvertently telling SwiftUI to destroy and recreate your data every time the screen redraws. Here is the technical breakdown of why this happens and the architectural pattern you must use to fix it.

The Problem: Ephemeral Views vs. Persistent State

To understand the bug, you must accept a core tenet of SwiftUI: Views are value types (Structs), not reference types.

In UIKit, a UIViewController was a persistent class instance. If you initialized a variable in viewDidLoad, it stayed there until the screen was deallocated. In SwiftUI, a View struct is merely a lightweight set of instructions. It is destroyed and recreated (discarded) whenever the layout system decides a redraw is necessary.

The "Crime Scene" Code

Look at the code below. This is the most common anti-pattern in SwiftUI development.

import SwiftUI

class ProfileViewModel: ObservableObject {
    @Published var username: String = "Guest"
    @Published var requestCount: Int = 0

    func loadUserData() {
        // Simulate a network delay
        DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
            self.username = "DevUser123"
            self.requestCount += 1
        }
    }
}

struct ProfileView: View {
    // ❌ ERROR: initializing an ObservedObject inside the View
    @ObservedObject var viewModel = ProfileViewModel()

    var body: some View {
        VStack {
            Text("User: \(viewModel.username)")
            Text("Requests: \(viewModel.requestCount)")
            
            Button("Reload Data") {
                viewModel.loadUserData()
            }
        }
    }
}

Why This Fails

When the parent of ProfileView redraws—perhaps because a TabBar switched or a system theme changed—SwiftUI creates a brand new instance of the ProfileView struct.

Because viewModel is initialized inline (= ProfileViewModel()), the new struct creates a new instance of the class. The old class instance (and all its state) is deallocated. Your data resets to "Guest" and 0 requests.

@ObservedObject is designed for Dependency Injection, not ownership. It implies that the data was created elsewhere and passed in. It does not provide a safe storage location outside the lifecycle of the view struct.

The Solution: @StateObject

Apple introduced @StateObject with iOS 14 to bridge this specific gap.

@StateObject tells SwiftUI: "I am the owner of this object. Please keep a reference to it in a safe memory cache separate from the View struct, and persist it across redraws."

When the ProfileView struct is destroyed and recreated, SwiftUI checks its internal storage. If a @StateObject for this view already exists, it injects the existing instance rather than creating a new one.

The Corrected Code

Here is the modern, stable solution using @StateObject.

import SwiftUI

// 1. The ViewModel remains a standard ObservableObject
class ProfileViewModel: ObservableObject {
    @Published var username: String = "Guest"
    @Published var requestCount: Int = 0

    func loadUserData() {
        DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
            self.username = "DevUser_Pro"
            self.requestCount += 1
        }
    }
}

struct ProfileView: View {
    // ✅ FIX: StateObject manages the lifecycle independently of the View Struct
    @StateObject private var viewModel = ProfileViewModel()

    var body: some View {
        VStack(spacing: 20) {
            Text("User: \(viewModel.username)")
                .font(.headline)
            
            Text("Requests: \(viewModel.requestCount)")
                .foregroundStyle(.secondary)
            
            Button("Reload Data") {
                viewModel.loadUserData()
            }
            .buttonStyle(.borderedProminent)
        }
    }
}

When to Use StateObject vs. ObservedObject

This distinction is critical for performance and data integrity. You should memorize this rule of thumb:

  1. @StateObject (The Owner): Use this when the View creates the ViewModel. This view owns the data.
  2. @ObservedObject (The Guest): Use this when the ViewModel is passed into the View as a parameter. This view merely watches the data.

Example: Parent-Child Relationship

A common architecture involves a parent view owning the data and passing it down to a child view.

struct ParentDashboard: View {
    // Parent OWNS the data
    @StateObject private var viewModel = ProfileViewModel()

    var body: some View {
        VStack {
            Text("Dashboard")
            // Pass the existing instance to the child
            ProfileStatusBadge(viewModel: viewModel)
        }
    }
}

struct ProfileStatusBadge: View {
    // Child WATCHES the data
    @ObservedObject var viewModel: ProfileViewModel

    var body: some View {
        Text("Status: \(viewModel.username)")
            .padding()
            .background(Color.blue.opacity(0.1))
            .cornerRadius(8)
    }
}

In this scenario, if ParentDashboard redraws, it keeps its @StateObject. It passes that same instance to ProfileStatusBadge. Even though ProfileStatusBadge is recreated, it receives the existing reference via dependency injection.

Pitfall: Initializing StateObject with Parameters

A common complexity arises when your ViewModel needs data passed in from the View's initializer to start up (e.g., a userID).

Since @StateObject initializes lazily, you cannot easily pass parameters to it inside the View's custom init.

Avoid doing this:

// ⚠️ Dangerous Territory
init(userID: String) {
    self._viewModel = StateObject(wrappedValue: ProfileViewModel(id: userID))
}

While syntactically valid in some contexts, this fights against SwiftUI's declarative nature. If the userID changes, the StateObject will not automatically recreate itself, leading to stale data bugs.

The Better Approach: Pass the userID to the View, and use .onAppear or .task (for async work) to trigger the data load within the existing ViewModel.

struct UserDetailView: View {
    let userID: String
    @StateObject private var viewModel = ProfileViewModel()

    var body: some View {
        VStack {
            if viewModel.isLoading {
                ProgressView()
            } else {
                Text(viewModel.username)
            }
        }
        .task(id: userID) { 
            // .task automatically cancels and restarts if userID changes
            await viewModel.fetchUser(id: userID)
        }
    }
}

Conclusion

If your SwiftUI view is resetting itself or ignoring updates, verify your initialization.

The rule is strict: If you type = MyViewModel(), the property wrapper on the left must be @StateObject. If you are accepting the variable as a parameter from a parent, use @ObservedObject.

By adhering to this memory ownership model, you align your code with the SwiftUI render loop, ensuring your app feels responsive and your data remains consistent.