
@StateObject & @ObservedObject in details
Property Wrapper Integration and Lifecycle — deep dive on @StateObject vs @ObservedObject
SwiftUI gives you two very similar-looking tools for connecting classes to the view layer: @StateObject and @ObservedObject. They both let a view react to changes from an ObservableObject, but they have very different responsibilities. Pick the wrong one and your view models will be behavior unexpectedly.
The short definition
@StateObject = creates and owns the object. SwiftUI stores it outside the view struct and gives you the same instance across body re-evaluations.
@ObservedObject = observes only. It subscribes to the object’s changes but does not own or store it for you.
Ownership is the whole story. Once you understand who owns the object, every mystery about lifecycle falls into place.
Why this matters — SwiftUI’s view recreation model
SwiftUI views are value types. SwiftUI rebuilds view structs frequently: when local state changes, when parents update, and during layout passes. If you put a reference-type (class) instance in a plain property of the view struct, it will be recreated with the view struct and you’ll lose state. @StateObject prevents that by telling SwiftUI to allocate and keep the instance in its managed storage for the life of that view’s identity.
Classic mistakes and concrete fixes
Mistake A — Creating a view model with @ObservedObject in the view (the most common trap)
Wrong: the view model gets reconstructed every time the view redraws.
import SwiftUI
import Combine
final class CounterVM: ObservableObject {
@Published var count = 0
init() { print("CounterVM init") }
deinit { print("CounterVM deinit") }
func inc() { count += 1 }
}
struct ParentBadView: View {
@State private var toggle = false
var body: some View {
VStack(spacing: 16) {
Button("Toggle parent state") { toggle.toggle() } // causes parent to re-evaluate
Text("Parent toggle: \(toggle.description)")
// Child created inline — has @ObservedObject var vm = CounterVM()
BadCounterView()
.border(Color.red)
}
.padding()
}
}
struct BadCounterView: View {
// <-- BAD: observed object created inline.
// This initializer runs every time the view struct is recreated.
@ObservedObject var vm = CounterVM() // ❌ wrong ownership
var body: some View {
VStack {
Text("Count: \(vm.count)")
Button("Inc") { vm.inc() }
}
.padding()
}
}
Expected console log when the view is recreated repeatedly:
CounterVM init
CounterVM deinit
CounterVM init
CounterVM deinit
...
Symptom: count resets. Presentations that rely on the VM (alerts, sheets) may dismiss because their presenter was deallocated.
First way to fix: Child owns the VM with @StateObject
struct ParentGoodAView: View {
@State private var toggle = false
var body: some View {
VStack(spacing: 16) {
Button("Toggle parent state") { toggle.toggle() }
Text("Parent toggle: \(toggle.description)")
GoodCounterView() // child owns VM via @StateObject
.border(Color.green)
}
.padding()
}
}
struct GoodCounterView: View {
// ✅ child becomes the owner. SwiftUI allocates and stores this instance once.
@StateObject private var vm = CounterVM()
var body: some View {
VStack {
Text("Count: \(vm.count)")
Button("Inc") { vm.inc() }
}
.padding()
}
}
Or we could let parent owns the VM and passes it down:
struct ParentGoodBView: View {
@State private var toggle = false
@StateObject private var sharedVM = CounterVM() // parent owns the VM
var body: some View {
VStack(spacing: 16) {
Button("Toggle parent state") { toggle.toggle() }
Text("Parent toggle: \(toggle.description)")
ChildObservingView(vm: sharedVM) // child only observes
.border(Color.blue)
}
.padding()
}
}
struct ChildObservingView: View {
@ObservedObject var vm: CounterVM // observes the parent's VM
var body: some View {
VStack {
Text("Count: \(vm.count)")
Button("Inc") { vm.inc() }
}
.padding()
}
}
Mistake B — Heavy work in init() of the view model
Putting network calls, database setup, or expensive computation directly inside init causes slow startup and makes duplicate inits visible during SwiftUI layout passes (previews and complex hierarchies might call init multiple times before the StateObject is stored).
Wrong:
final class SyncVM: ObservableObject {
@Published var value: String = ""
init() {
// heavy sync work
fetchFromNetworkSynchronously() // ❌ blocks and may run multiple times
}
}
Symptom: UI jank, slower previews, confusing double initialization logs.
Fix: keep init lightweight and perform async work in onAppear, .task, or a start() method.
final class AsyncVM: ObservableObject {
@Published var value: String = ""
func start() {
Task {
let result = await fetchFromNetwork()
await MainActor.run { self.value = result }
}
}
}
struct LoaderView: View {
@StateObject private var vm = AsyncVM()
var body: some View {
Text(vm.value.isEmpty ? "Loading…" : vm.value)
.task { vm.start() } // starts once when view enters scene
}
}
This pattern avoids heavy work in init and defers side-effects until the view is actually active. From the above behavior, @StateObject is created the first time the view is inserted into the real view hierarchy. However, SwiftUI can instantiate view structs for layout and measurement before that moment — which sometimes makes it look like initializers ran more than once during preview or complex layouts. In practice, the instance that matters is the one SwiftUI stores and supplies to subsequent body evaluations.
Here is the rule of thumb: don’t rely on init side effects for one-time app-level setup. Use onAppear, .task, or an explicit start() call for side-effects that must run once.
Mistake C — Using @StateObject but wanting to inject instances (DI gotcha)
If you want a view to accept an externally created VM (for tests or DI), don’t declare @StateObject private var vm = MyVM() and then expect an injected instance to replace it. SwiftUI will ignore the external instance after the wrapper has been set up.
Incorrect injection attempt:
struct ProfileView: View {
@StateObject private var vm = ProfileVM() // hard-coded instance
init(vm: ProfileVM) { /* trying to inject — doesn't work */ }
}
Fix: initialize the wrapper from the initializer using the underscore syntax:
struct ProfileView: View {
@StateObject private var vm: ProfileVM
init(vm: ProfileVM) {
_vm = StateObject(wrappedValue: vm) // ✅ use injected instance as the stored value
}
var body: some View {
Text(vm.username)
}
}
Now tests or parent code can pass a mock VM and SwiftUI will use the injected instance as the StateObject's initial value.
Mistake D — Putting @StateObject in a child when you needed shared state
@StateObject gives each view its own owned instance. For lists or sibling views that must share a single model, this creates duplicates.
Example where this is wrong:
struct RowView: View {
@StateObject private var rowVM = CounterVM() // each row gets its own VM
var body: some View {
HStack {
Text("Count \(rowVM.count)")
Button("Inc") { rowVM.inc() }
}
}
}
Symptom: It looks like you have many counters and they don't sync (as expected). But if you wanted a single counter shared between rows, this is wrong.
Fix: own the model at a higher level and pass as @ObservedObject:
struct TableView: View {
@StateObject private var sharedVM = CounterVM()
var body: some View {
ForEach(0..<3) { _ in
RowView(vm: sharedVM)
}
}
}
struct RowView: View {
@ObservedObject var vm: CounterVM
var body: some View { /* uses vm */ }
}
Now all rows observe the same CounterVM.
Mistake E — Expecting to replace a @StateObject instance later (you can’t)
A @StateObject belongs to SwiftUI. Once the view is created, SwiftUI stores the object and keeps it alive for the view’s whole lifecycle. This means you cannot later assign a new instance to that property, and re-passing a different instance from the parent won’t replace it either. The child takes the value only at the moment it is created. Developers often expect the child to “pick up” a new instance, but SwiftUI keeps the first one.
Wrong — parent tries to pass a different instance, child uses @StateObject:
struct Parent: View {
@State private var useOther = false
private let first = CounterVM(tag: "first")
private let second = CounterVM(tag: "second")
var body: some View {
VStack {
Button("Swap instance") { useOther.toggle() }
Child(vm: useOther ? second : first) // looks like it should change…
}
}
}
struct Child: View {
@StateObject private var vm: CounterVM
init(vm: CounterVM) {
_vm = StateObject(wrappedValue: vm) // …but this is only used once at creation
}
var body: some View {
Text("VM tag: \(vm.tag)")
}
}
Even though the parent switches between first and second, the child continues showing only the initial VM’s tag. SwiftUI does not replace the stored StateObject.
Fix: mutate the object you already own, or let the parent truly own the instance and pass it down using @ObservedObject. If you need to reset or change behavior, prefer internal methods:
final class CounterVM: ObservableObject {
@Published var count = 0
func reset() { count = 0 } // change internal state instead of replacing the object
}
If you absolutely must replace the whole instance, do it at the parent level (for example by storing the VM in a @State property and passing it to the child as an @ObservedObject).
Closing notes
@StateObject and @ObservedObject may look similar, but the difference is ownership. Once you start thinking in terms of who owns the model, most lifecycle surprises disappear. Use @StateObject when the view is the source of truth and must keep the object alive across redraws, and use @ObservedObject when the model lives elsewhere and the view only needs to watch it. Small shifts in ownership lead to big differences at runtime, so keep initializers simple, avoid heavy work or side effects in init, and inject dependencies with _vm = StateObject(wrappedValue:) only when the view truly owns the instance. And always check behavior in the simulator or on a device, because previews don’t always reveal lifecycle details. With these habits, SwiftUI’s MVVM flow becomes much more predictable and easier to reason about.