Based on this answer: What is the difference between ObservedObject and StateObject in SwiftUI
And the Apple documentation code here: https://developer.apple.com/documentation/swiftui/managing-model-data-in-your-app
In SwiftUI app, a @StateObject
property wrapper should be used when a View
instantiates the object itself, so that the object won't be recreated during a view update.
If the object is instantiated somewhere else, an @ObservedObject
wrapper should be used instead.
However, there is a fine line which makes it a bit unclear: what if the object is instantiated somewhere else, but "injected" to the View
and then the view is the sole owner / holder of that object? Should it be @StateObject
or @ObservedObject
?
Sample code to get the point illustrated:
import SwiftUI
import Combine
import Foundation
struct ViewFactory {
func makeView() -> some View {
let viewModel = ViewModel()
return NameView(viewModel)
}
}
final class ViewModel: ObservableObject {
@Published var name = ""
init() {}
}
struct NameView: View {
// Should this be an `@ObservedObject` or `@StateObject`?
@ObservedObject var viewModel: ViewModel
init(_ viewModel: ViewModel) {
self.viewModel = viewModel
}
var body: some View {
Text(viewModel.name)
}
}
Based on this article: https://www.hackingwithswift.com/quick-start/swiftui/whats-the-difference-between-observedobject-state-and-environmentobject
There is one important difference between @StateObject and @ObservedObject, which is ownership – which view created the object, and which view is just watching it.
The rule is this: whichever view is the first to create your object must use @StateObject, to tell SwiftUI it is the owner of the data and is responsible for keeping it alive. All other views must use @ObservedObject, to tell SwiftUI they want to watch the object for changes but don’t own it directly.
it appears that if the View
to instantiate the ViewModel
, it has to be declared with @StateObject
. My code is very similar, the only difference is that the ViewModel
is created elsewhere, but the View
"owns" it after the initialization.
This is a really interesting question. There's some subtle behavior going on here.
First, notice that you can't just change @ObservedObject
to @StateObject
in NameView
. It won't compile:
struct NameView: View {
@StateObject var viewModel: ViewModel
init(_ viewModel: ViewModel) {
self.viewModel = viewModel
// ^ 🛑 Cannot assign to property: 'viewModel' is a get-only property
}
...
}
To make it compile, you have to initialize the underlying _viewModel
stored property of type StateObject<ViewModel>
:
struct NameView: View {
@StateObject var viewModel: ViewModel
init(_ viewModel: ViewModel) {
_viewModel = .init(wrappedValue: viewModel)
}
...
}
But there's something hidden there. StateObject.init(wrappedValue:)
is declared like this:
public init(wrappedValue thunk: @autoclosure @escaping () -> ObjectType)
So the expression given as an argument (just viewModel
above) is wrapped up in a closure, and is not evaluated right away. That closure is stored for later use, which is why it is @escaping
.
As you might guess from the hoops we have to jump through to make it compile, this is a weird way to use StateObject
. The normal use looks like this:
struct NormalView: View {
@StateObject var viewModel = ViewModel()
var body: some View {
Text(viewModel.name)
}
}
And doing it the weird way has some drawbacks. To understand the drawbacks, we need to look at the context in which makeView()
or NormalView()
is evaluated. Let's say it looks like this:
struct ContentView: View {
@Binding var count: Int
var body: some View {
VStack {
Text("count: \(count)")
NormalView()
ViewFactory().makeView()
}
}
}
When count
's value changes, SwiftUI will ask ContentView
for its body
again, which will evaluate both NormalView()
and makeView()
again.
So body
calls NormalView()
during this second evaluation, which creates another instance of NormalView
. NormalView.init
creates a closure which calls ViewModel()
, and passes the closure to StateObject.init(wrappedValue:)
. But StateObject.init
does not evaluate this closure immediately. It stores it away for later use.
Then body
calls makeView()
, which does call ViewModel()
immediately. It passes the new ViewModel
to NameView.init
, which wraps the new ViewModel
in a closure and passes the closure to StateObject.init(wrappedValue:)
. This StateObject
also doesn't evaluate the closure immediately, but the new ViewModel
has been created regardless.
Some time after ContentView.body
returns, SwiftUI wants to call NormalView.body
. But before doing so, it has to make sure the StateObject
in this NormalView
has a ViewModel
. It notices that this NormalView
is replacing a prior NormalView
at the same position in the view hierarchy, so it retrieves the ViewModel
used by that prior NormalView
and puts it in the StateObject
of the new NormalView
. It does not execute the closure given to StateObject.init
, so it does not create a new ViewModel
.
Even later, SwiftUI wants to call NameView.body
. But before doing so, it has to make sure the StateObject
in this NameView
has a ViewModel
. It notices that this NameView
is replacing a prior NameView
at the same position in the view hierarchy, so it retrieves the ViewModel
used by that prior NameView
and puts it in the StateObject
of the new NameView
. It does not execute the closure given to StateObject.init
, and so it does not use the ViewModel
referenced by that closure. But the ViewModel
was created anyway.
So there are two drawbacks to the weird way in which you're using @StateObject
:
ViewModel
each time you call makeView
, even though that ViewModel
may never be used. This may be expensive, depending on your ViewModel
.ViewModel
while the ContentView.body
getter is running. If creating the ViewModel
has side effects, this may confuse SwiftUI. SwiftUI expects the body
getter to be a pure function. In the NormalView
case, SwiftUI is calling the StateObject
's closure at a known time when it may be better prepared to handle side effects.So, back to your original question:
Should it be
@StateObject
or@ObservedObject
?
Well, ha ha, that's difficult to answer without seeing an example that's less of a toy. But if you do need to use @StateObject
, you should probably try to initialize it in the ‘normal’ way.