Choosing @StateObject over @ObservedObject in SwiftUI: A Practical Guide
Learn when to use @StateObject instead of @ObservedObject in SwiftUI, see a concrete counter example, understand memory implications, and avoid common pitfalls when building robust view‑model lifecycles.
14 Sept 2026, 22:08 UTC

Problem: View‑Model Duplication on Navigation and List Re‑creation
When building SwiftUI apps that rely on observable view models, developers often encounter duplicated state or memory leaks. A common symptom is a counter that resets when navigating away or a row that shows stale data after scrolling. The root cause is usually a misuse of @ObservedObject or @StateObject with the wrong scope.
Thesis: Use @StateObject for the owning view and @ObservedObject for children.
In SwiftUI, @StateObject guarantees that an ObservableObject instance is created only once for the lifetime of the view that declares it. @ObservedObject merely observes an existing instance; it does not own it. When a view is recreated—by navigation, tab switching, or list row reuse—@ObservedObject will bind to a new instance if you also declare it with @ObservedObject inside that view. This leads to the counter resetting or state duplication.
Concrete Example: A Counter Across Navigation
Below is a minimal SwiftUI app that demonstrates the difference. The CounterViewModel is a class that publishes an integer. The CounterView owns the model with @StateObject. The DetailView receives the same instance via @ObservedObject passed as a parameter.
import SwiftUI
final class CounterViewModel: ObservableObject {
@Published var count: Int = 0
deinit { print("CounterViewModel deinit") }
}
struct CounterView: View {
@StateObject private var viewModel = CounterViewModel()
var body: some View {
NavigationStack {
VStack(spacing: 20) {
Text("Count: \(viewModel.count)")
.font(.largeTitle)
Button("Increment") { viewModel.count += 1 }
NavigationLink("Go to Detail", value: viewModel)
}
.navigationDestination(for: CounterViewModel.self) { vm in
DetailView(viewModel: vm)
}
}
}
}
struct DetailView: View {
@ObservedObject var viewModel: CounterViewModel
var body: some View {
VStack {
Text("Detail Count: \(viewModel.count)")
Button("Increment") { viewModel.count += 1 }
}
}
}
@main
struct DemoApp: App {
var body: some Scene { WindowGroup { CounterView() } }
}
Run the app, tap Increment, then navigate to Detail. The counter value stays the same because the same CounterViewModel instance is shared. When you return to CounterView and increment again, the value continues to increase. The console will print CounterViewModel deinit only once when the root view is removed.
What Happens with @ObservedObject Alone?
If you replace @StateObject with @ObservedObject in CounterView and instantiate the model there, SwiftUI will create a new instance every time the view is recreated. Navigation, tab switching, or a list row that re‑renders will all trigger new models, causing the counter to reset to zero. The deinit message will never appear because the previous instance is retained by the system until the view hierarchy changes completely.
Trade‑offs and Limitations
- Memory Usage:
@StateObjectholds a strong reference, so ensure no retain cycles via closures in the view model. - Re‑creation: Using
@StateObjectinside aListrow without a stableidcan spawn multiple model instances. Wrap the row in aViewthat has a unique identifier. - OS Support:
@StateObjectis available from iOS 14 / macOS 11. For projects targeting earlier OS versions, fall back to manual lifecycle handling with@ObservedObject. - Declarative Constraints: You cannot declare a property as both
@StateObjectand@ObservedObjectin the same view; the compiler will error.
Actionable Checklist for Developers
- Identify the owning view: the view that creates the model. Declare it with
@StateObject. - Pass the model to child views as a parameter and declare it with
@ObservedObject. - When using
ListorForEach, ensure each row has a stableidand that@StateObjectis not placed in a row that gets recreated frequently. - Add a
deinitprint in the view model and run the app to confirm the message appears once when the view hierarchy is torn down. - Review closures in the view model: if they capture
self, use[weak self]to avoid retain cycles.
Closing Thoughts
Choosing the right property wrapper is a small decision that saves you from subtle bugs and memory leaks. By owning the view model with @StateObject in the root view and observing it with @ObservedObject in children, you get a single, persistent instance that survives navigation, tab changes, and other view recreations. Keep an eye on the view hierarchy, especially in lists, and verify deinitialization to maintain a healthy SwiftUI app.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.