iosswiftxcodeswiftuicombine

@StateObject vs @ObservedObject when passed externally but owned by the view


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.


Solution

  • 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:

    1. You're creating a new ViewModel each time you call makeView, even though that ViewModel may never be used. This may be expensive, depending on your ViewModel.
    2. You're creating the 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.