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
ObservableObjectand@Published - Why
@StateObjectmust be used in the view that creates the object, and@ObservedObjectin views that receive it - How
@Publishedproperties 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
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
class FruitViewModel: ObservableObject— Must be aclassbecause SwiftUI views need a reference to the same object. If it were astruct, passing it to a child view would create a copy, and changes in the child would not propagate back to the parent.@Published var fruitArray: [FruitModel] = []— Every assignment tofruitArray— including.append,.remove, and direct reassignment — fires aobjectWillChangepublisher. SwiftUI's binding subscriptions hear this and schedule a re-render for all views observing this view model.init() { getFruits() }— Starts the data fetch immediately when the view model is created. Because@StateObjectcreates the object exactly once, thisinitruns exactly once per view lifetime — ideal for bootstrapping initial data.@StateObject var fruitViewModel: FruitViewModel = FruitViewModel()— The= FruitViewModel()expression creates the instance, and@StateObjecttakes ownership. If you later add state to the parent view and the parent re-renders, SwiftUI skips theFruitViewModel()initialization on subsequent renders — the stored object is reused. This is the behavior you'd lose with@ObservedObjecthere..onDelete(perform: fruitViewModel.deleteFruit)— Passes the method as a function reference. SwiftUI calls it with anIndexSetwhen the user swipes to delete a row. BecausefruitArrayis@Published, the list re-renders immediately after the deletion.NavigationLink(destination: RandomScreen(fruitViewModel: fruitViewModel))— Passes the existingFruitViewModelinstance toRandomScreen. This is a reference type, soRandomScreengets the same object, not a copy. Any deletions made inViewModelBootcampare immediately visible inRandomScreenbecause they share state.@ObservedObject var fruitViewModel: FruitViewModelinRandomScreen— Observes the instance received from the parent.@ObservedObjectdoes not create or own the object — it just tells SwiftUI to re-renderRandomScreenwhenever 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
@StateObjectin the view that creates the view model — it ensures the object is initialized once and lives as long as the view. - Use
@ObservedObjectin child views that receive an existing view model instance — they observe changes but do not own the object. - Mark properties with
@Publishedin yourObservableObjectto make SwiftUI automatically re-render all observing views whenever those values change.
Last updated: June 27, 2026