I’m trying to use @Observable, but I’ve noticed that the View doesn’t holt the ViewModel with @State. Every time when something changes in the ContentView, just like a letter in a TextField, the ViewModel calls the init.
Why does this happen and not in the old way with ObservableObject?
I would be very grateful if someone could explain to me how this works and how I can do it better.
example:
struct ContentView: View {
@State var text = ""
var body: some View {
TextField("Enter text", text: $text)
SubView()
}
}
@Observable class SubViewModel {
var text: String = "Hello, World!"
init() {
// -> reload
print("init SubViewModel")
}
}
struct SubView: View {
@State private var viewModel: SubViewModel
init() {
_viewModel = State(wrappedValue: SubViewModel())
}
var body: some View {
Text(viewModel.text)
}
}
old way
class SubViewModel: ObservableObject {
@Published var text: String = "Hello, World!"
init() {
// -> doesn't reload
print("init SubViewModel")
}
}
struct SubView: View {
@StateObject private var viewModel: SubViewModel
init() {
_viewModel = StateObject(wrappedValue: SubViewModel())
}
var body: some View {
Text(viewModel.text)
}
}
2
Answers
According to the docs Managing data , with
@Observable class
, in a view forms a dependency on an observable data model object, and"When a tracked property changes, SwiftUI updates the view. If other properties change that body doesn’t read, the view is unaffected and avoids unnecessary updates."
In the original code, the
SubView
re-initializes theSubViewModel
every time the
text
in theTextField
changes, becausethe
SubView
itself is being re-created. And this isbecause the
ContentView
itself is being refreshed due to the change of@State var text...
. This is the fundamental behavior of@State
.To avoid having the
SubView
re-init when thetext
in theTextField
is changed, try this approach, moving the
SubViewModel
declaration in theContentView
(higher in the hierarchy)and passing it to the
SubView
, leveraging what the docs mention.This is because
StateObject.init
takes a closure, whereasState.init
does not.Every time the view updates (e.g. the text in a text field changes), your view’s
init
gets called, causingStateObject.init
/State.init
(depending on which one you are using) to be called.Since
State.init
just takes in the value directly, the argument expression (SubViewModel()
) is always evaluated immediately, hence callingSubViewModel.init
. This doesn’t replace what was originally stored in the@State
and reset it to a new instance ofSubViewModel
though – if the@State
is already storing an instance ofSubViewModel
, SwiftUI will just discard the newly createdSubViewModel
.The documentation of
@State
acknowledges this behaviour:On the other hand,
StateObject.init
takes a closure. This means that the argument passed to it is not immediately evaluated. The argument expression is wrapped in a closure, as if you have writtenSwiftUI stores this closure somewhere and can call this closure whenever necessary. When there is a view update, SwiftUI would choose not to call this closure again, if there is already an instance of
SubViewModel
stored in theStateObject
, and that is what happens.