skip to Main Content

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


  1. 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 the SubViewModel
    every time the text in the TextField changes, because
    the SubView itself is being re-created. And this is
    because 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 the text in the TextField
    is changed, try this approach, moving the SubViewModel declaration in the ContentView (higher in the hierarchy)
    and passing it to the SubView, leveraging what the docs mention.

     struct ContentView: View {
        @State private var text = ""
        @State private var viewModel = SubViewModel()  // <-- here, source of data truth
        
        var body: some View {
            TextField("Enter text", text: $text)
            SubView(viewModel: viewModel) // <-- here
        }
    }
    
    @Observable class SubViewModel {
        var text: String = "Hello, World!"
        
        init() {
            print("---> init SubViewModel")
        }
    }
    
    struct SubView: View {
        let viewModel: SubViewModel // <-- here
    
        var body: some View {
            Text(viewModel.text)
        }
    }
    
    Login or Signup to reply.
  2. This is because StateObject.init takes a closure, whereas State.init does not.

    //StateObject.init
    init(wrappedValue thunk: @autoclosure @escaping () -> ObjectType)
    
    // State.init
    init(wrappedValue value: Value)
    

    Every time the view updates (e.g. the text in a text field changes), your view’s init gets called, causing StateObject.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 calling SubViewModel.init. This doesn’t replace what was originally stored in the @State and reset it to a new instance of SubViewModel though – if the @State is already storing an instance of SubViewModel, SwiftUI will just discard the newly created SubViewModel.

    The documentation of @State acknowledges this behaviour:

    A State property always instantiates its default value when SwiftUI
    instantiates the view. For this reason, avoid side effects and
    performance-intensive work when initializing the default value. For
    example, if a view updates frequently, allocating a new default object
    each time the view initializes can become expensive. Instead, you can
    defer the creation of the object using the task(priority:_:)
    modifier, which is called only once when the view first appears:

    struct ContentView: View {
        @State private var library: Library?
    
    
        var body: some View {
            LibraryView(library: library)
                .task {
                    library = Library()
                }
        }
    }
    

    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 written

    StateObject(wrappedValue: { SubViewModel() })
    

    SwiftUI 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 the StateObject, and that is what happens.

    Login or Signup to reply.
Please signup or login to give your own answer.
Back To Top
Search