Skip to content

How to use @ObservableObject and @StateObject in SwiftUI | SwiftUI Bootcamp #50

When data outlives a single view — when it needs to survive re-renders, persist across navigation, or be shared — @State isn't enough. ObservableObject + @StateObject is the foundation of the SwiftUI view model pattern, and understanding the @StateObject vs. @ObservedObject distinction prevents one of the most common and baffling bugs in SwiftUI apps. After this lesson you'll understand how to build a view model, own it correctly, and pass it to child views.

What You'll Learn

  • How to create a view model class using ObservableObject and @Published
  • Why @StateObject must be used in the view that creates the object, and @ObservedObject in views that receive it
  • How @Published properties trigger automatic re-renders whenever their values change
  • How to navigate to a second view and pass an existing view model without creating a new copy

Mental Model

Think of ObservableObject as a whiteboard in a shared office. @Published properties are items written on that whiteboard — whenever anyone erases and rewrites something, everyone in the office who is looking at it (all views using @StateObject or @ObservedObject) immediately sees the new content and can update their work accordingly.

@StateObject is the person who owns the whiteboard. They buy it, set it up, and are responsible for it. Even if the office layout (the view hierarchy) changes, the whiteboard remains theirs. @ObservedObject is a colleague who has been handed a reference to the same whiteboard — they can read it and write to it, but if they leave the room and come back, someone else must bring the board; they don't own it.

Detailed Explanation

ObservableObject is a protocol (from the Combine framework) that a class can conform to. Any property of that class marked @Published automatically publishes a change notification every time its value is set. SwiftUI subscribes to these notifications and re-renders any view that is observing the object.

@StateObject tells SwiftUI: "this view creates and owns this object." SwiftUI initializes it exactly once and keeps it alive for the entire lifetime of the view. Even when the view struct is recreated (which happens frequently), the object inside @StateObject is not recreated. This is the crucial difference from @ObservedObject: if you use @ObservedObject in the view that creates the object, the object gets recreated every time the view body evaluates, destroying all state.

@ObservedObject tells SwiftUI: "someone else gave me this object; I observe it but I don't own it." Use this in child views that receive an existing instance. The child can read and write @Published properties, and all changes propagate to all other views observing the same object — because it's the same object, not a copy.

The separation of @StateObject (owner) and @ObservedObject (observer) is how you avoid duplicating state. The fruit data lives in FruitViewModel. ViewModelBootcamp owns it with @StateObject. RandomScreen receives it with @ObservedObject. Both views see the same list; deleting a fruit in ViewModelBootcamp immediately removes it from RandomScreen too.

Code Structure

ViewModelBootcamp.swift contains four declarations. FruitModel is the data struct. FruitViewModel is the ObservableObject class that owns the array and exposes getFruits() and deleteFruit(index:). ViewModelBootcamp creates the view model with @StateObject and passes it to RandomScreen. RandomScreen observes it with @ObservedObject — demonstrating that both views share the same data.

Complete Code

ViewModelBootcamp.swift

swift
import SwiftUI

struct FruitModel: Identifiable {
    let id: String = UUID().uuidString
    let name: String
    let count: Int
}

class FruitViewModel: ObservableObject { // class (not struct) because it needs reference semantics for sharing
    
    @Published var fruitArray: [FruitModel] = [] // any assignment triggers a re-render in all observing views
    @Published var isLoading: Bool = false        // true during the simulated network fetch
    
    init() {
        getFruits() // load data immediately when the view model is created
    }
    
    func getFruits() {
        let fruit1 = FruitModel(name: "Orange", count: 1)
        let fruit2 = FruitModel(name: "Banana", count: 2)
        let fruit3 = FruitModel(name: "Watermelon", count: 88)
        
        isLoading = true // triggers spinner in the view immediately
        DispatchQueue.main.asyncAfter(deadline: .now() + 3.0) {
            self.fruitArray.append(fruit1) // @Published fires here — view updates after each append
            self.fruitArray.append(fruit2)
            self.fruitArray.append(fruit3)
            self.isLoading = false         // hides the spinner once all items are loaded
        }
        
    }
    
    func deleteFruit(index: IndexSet) {
        fruitArray.remove(atOffsets: index) // removes item at the swiped position; @Published fires, List re-renders
    }
    
}

struct ViewModelBootcamp: View {
    
    // @StateObject -> USE THIS ON CREATION / INIT
    // @ObservedObject -> USE THIS FOR SUBVIEWS
    @StateObject var fruitViewModel: FruitViewModel = FruitViewModel() // creates and owns the view model
    
    var body: some View {
        NavigationView {
            List {
                
                if fruitViewModel.isLoading {
                    ProgressView() // shown while isLoading is true (during the 3-second simulated fetch)
                } else {
                    ForEach(fruitViewModel.fruitArray) { fruit in
                        HStack {
                            Text("\(fruit.count)")
                                .foregroundColor(.red)
                            Text(fruit.name)
                                .font(.headline)
                                .bold()
                        }
                    }
                    .onDelete(perform: fruitViewModel.deleteFruit) // passes the function reference; swipe-to-delete calls it
                }
            }
            .listStyle(GroupedListStyle())
            .navigationTitle("Fruit List")
            .navigationBarItems(trailing:
                                    NavigationLink(
                                        destination: RandomScreen(fruitViewModel: fruitViewModel), // passes the SAME instance, not a copy
                                        label: {
                                            Image(systemName: "arrow.right")
                                                                .font(.title)
                                        })
            )
        }
    }
    
}


struct RandomScreen: View {
    
    @Environment(\.presentationMode) var presentationMode // allows programmatic dismissal if needed
    @ObservedObject var fruitViewModel: FruitViewModel    // observes the existing instance passed from ViewModelBootcamp
    
    var body: some View {
        ZStack {
            Color.green.ignoresSafeArea()
            
            VStack {
                ForEach(fruitViewModel.fruitArray) { fruit in
                    Text(fruit.name)
                        .foregroundColor(.white)
                        .font(.headline)
                }
            }
        }
    }
}

struct ViewModelBootcamp_Previews: PreviewProvider {
    static var previews: some View {
        ViewModelBootcamp()
    }
}

Code Walkthrough

  1. class FruitViewModel: ObservableObject — Must be a class because SwiftUI views need a reference to the same object. If it were a struct, passing it to a child view would create a copy, and changes in the child would not propagate back to the parent.

  2. @Published var fruitArray: [FruitModel] = [] — Every assignment to fruitArray — including .append, .remove, and direct reassignment — fires a objectWillChange publisher. SwiftUI's binding subscriptions hear this and schedule a re-render for all views observing this view model.

  3. init() { getFruits() } — Starts the data fetch immediately when the view model is created. Because @StateObject creates the object exactly once, this init runs exactly once per view lifetime — ideal for bootstrapping initial data.

  4. @StateObject var fruitViewModel: FruitViewModel = FruitViewModel() — The = FruitViewModel() expression creates the instance, and @StateObject takes ownership. If you later add state to the parent view and the parent re-renders, SwiftUI skips the FruitViewModel() initialization on subsequent renders — the stored object is reused. This is the behavior you'd lose with @ObservedObject here.

  5. .onDelete(perform: fruitViewModel.deleteFruit) — Passes the method as a function reference. SwiftUI calls it with an IndexSet when the user swipes to delete a row. Because fruitArray is @Published, the list re-renders immediately after the deletion.

  6. NavigationLink(destination: RandomScreen(fruitViewModel: fruitViewModel)) — Passes the existing FruitViewModel instance to RandomScreen. This is a reference type, so RandomScreen gets the same object, not a copy. Any deletions made in ViewModelBootcamp are immediately visible in RandomScreen because they share state.

  7. @ObservedObject var fruitViewModel: FruitViewModel in RandomScreen — Observes the instance received from the parent. @ObservedObject does not create or own the object — it just tells SwiftUI to re-render RandomScreen whenever the view model publishes changes.

Common Mistakes

Mistake: Using @ObservedObject in the view that creates the view model
If ViewModelBootcamp used @ObservedObject var fruitViewModel = FruitViewModel(), the view model would be recreated every time the parent view re-renders — destroying all loaded data and restarting the fetch. @StateObject is the only correct wrapper for the creating view.

Mistake: Using @StateObject in a child view that receives the view model
Using @StateObject in RandomScreen would create a new FruitViewModel instance, completely separate from the parent's. Changes in the parent would not appear in the child. Use @ObservedObject in every view that receives an already-existing object.

Mistake: Forgetting that @Published mutations must happen on the main thread
SwiftUI requires all state changes that trigger re-renders to happen on the main thread. Inside DispatchQueue.main.asyncAfter, this is guaranteed. But if you use a background URLSession completion handler, you must dispatch back to the main queue before setting @Published properties, or you'll see "Publishing changes from background threads is not allowed" warnings and potential crashes.

Key Takeaways

  • Use @StateObject in the view that creates the view model — it ensures the object is initialized once and lives as long as the view.
  • Use @ObservedObject in child views that receive an existing view model instance — they observe changes but do not own the object.
  • Mark properties with @Published in your ObservableObject to make SwiftUI automatically re-render all observing views whenever those values change.

Last updated: June 27, 2026

Released under the MIT License.