@Observable init() called multiple times by @State, different behavior to @StateObject

:wave: I am trying to adapt @Observation in my app, but some behavioral differences confuse me.

Here is a minimal code example:

Code
class MyModelClassic: ObservableObject {
    @Published var someData: String

    init() {
        print("MyModelClassic.init()")
        someData = "Hello"
        print(_someData)
        DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
            self.someData = "Bye"
        }
    }
}

@Observable
class MyModelObservable {
    var someData: String

    init() {
        print("MyModelObservable.init()")
        someData = "Hello"
        print(_someData)
        DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
            self.someData = "Bye"
        }
    }
}

struct InnerView: View {
    @State private var model = MyModelObservable()
    @StateObject private var modelClassic = MyModelClassic()

    var body: some View {
        let _ = Self._printChanges()
        Text(model.someData)
        Text(modelClassic.someData)
    }
}

struct ParamsView: View {
    @State private var counter = 0

    var body: some View {
        VStack {
            InnerView()
            Text(String(counter)).font(.title)
            Stepper("Count", value: $counter)
        }
        .padding()
    }
}

It seems using @State caused the models initializer getting called multiple times, while it gets called only once when using @StateObject.

The UI behaves identical: However, I do not understand how this is possible.

Using the Stepper will cause a redraw of InnerView, which in term will trigger a recreating of MyModelObservable.

  1. MyModelObservable sets someData in its initializer. Why does this not show up in the UI? (It will if we remove @State)

It seems SwiftUI is creating a second object, and then compares them. Plus somehow ignores changes to observable properties during the second init. But I have no idea what actually happens under the hood here. Does anyone have some more info on this?

This can open the door for some very serious bugs. E.g. in my app, I need to bind the lifetime of an object to a Scene Window and I am using @StateObject for this. Having the initializer of a "heavy" object called that many times was very problematic. I am not aware of an alternative way to do this.

I suspect the difference is because StateObject.init(wrappedValue:) has @autoclosure applied but State.init(wappedValue) doesn't.

Perhaps SwiftUI just discards that second (and all later) objects somehow because they shouldn't be instantiated in the first place? That seems awkward and it should be mentioned in migration guide article at least.

I print MyModelObservable instances' ObjectIdentifier in their init() and deinit to observe their life cycle. The log:

MyModelObservable:
init: ObjectIdentifier(0x0000000282d4b930)

MyModelObservable:
init: ObjectIdentifier(0x0000000282d7d620)

MyModelObservable:
init: ObjectIdentifier(0x0000000282d788a0)
deinit: ObjectIdentifier(0x0000000282d7d620)

MyModelObservable:
init: ObjectIdentifier(0x0000000282d78e70)
deinit: ObjectIdentifier(0x0000000282d788a0)

So the second and later instances are destroyed indeed, though the pattern isn't exactly the same as I thought (I had thought they were destroyed immediately after they were created).

An elegant hack. You can use a singleton instance in initialization code

@State private var model = MyModelObservable.shared

Not recommended.

Because at some time when the View and State did finish their lifetime, the original code will create a new MyModelObservable to use. But the shared one will cause it have some old value here.

What I do is wrap the Observable in an ObservableObject to hold the single intended instance

@Observable class Foo { 
    var value = 0 
}

class FooHolder: ObservableObject {
    let foo = Foo()
}

struct MyView: View {
    @StateObject var holder = FooHolder()
    var body: some View {
        Text("\(holder.foo.value)")
    }
}

The name isn’t as clean but I prefer that over pointless instantiation. However it gets worse if you want a Binding where my solution is to split it between 2 views, one to hold the instance the other for Bindable.

struct MyView: View {
    @StateObject var holder = FooHolder()
    var body: some View {
        MyViewReal(foo: holder.foo)
    }
}

struct MyViewReal: View {
    @Bindable var foo: Foo
    var body: some View {
        Stepper("", value: $foo.value)
    }
}

And of course you can make a generic holder class but a generic property name makes it unsatisfying to work with

class ObservableHolder<T: Observable>: ObservableObject {
    let object: T
    init(_ obj: T) { object = obj }
}
1 Like

Also, the docs for State suggest that for “heavy” objects to make the property optional and create it one time in a task modifier.

1 Like

Thanks for the information. I didn't know the behavior was well documented. I only read a migration article a while back and didn't know @State's document had been updated.

PS: I like the task modifier approach suggested in the document. May I ask why you prefer your ObservableObject wrapper approach over the task modifier approach?

1 Like

Honestly it’s probably that I’m not a very good programmer lol. I stay with what I know and thoroughly tested and I’m still learning the ins and outs of concurrency so I shy away from .task. I’m just more comfortable knowing exactly when models are instantiated, feels more tangible, but can foresee switching to .task when I get a robust grasp of concurrency.

Haha :D I don't use concurrency often either, because my app doesn't have server side.

I later realized the task modifier approach seems not satisfying either.

  1. I doubt if it works if the object's init() requires arguments. An obvious solution is to save these arguments in the view's properties. But what will happen if the view's init() is called again? Will a new task invoked to create a different instance? See more on this in item 2.

  2. Since task modifier was designed to run asynchronous func, it leads to the question why the doc doesn't suggest using onAppear() instead. I believe it's because onAppear() can be called multiple times in a view's life cycle (for example, user navigates from view A to view B and then navigates back). So, is it guaranteed that the code in task modifier runs only once in a view's life cycle? Both State and task's documents seem to suggest that, I haven't done experiments on this, but I wonder how is it implemented.

    I think an alternative solution is to implement a custom modifier, say, onFirstAppear() and put object initiatlization code there. This approach have no problem in supporting initialization arguments too (see item 1 above).

  3. Take the code snippet in Apple doc for example, since we know for sure the object can't be nil in the view's life cycle, is it OK to use implicitly unwrapped optional? If yes, that would save a lot of unnecessary check in the code.

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

    IIUC, however, it's not feasible, because when body is first called, LibraryView(library: library) is evaluated before task modifier is called. The object is nil at that moment. So using implicitly unwrapped optional would cause LibraryView crash.

Thanks for the info! I also didn't see that the docs got updated for @State.

I still think they should update the Observation migration guide, because they make it sound that @State is a stand in replacement for @StateObject - which it clearly isn't.

Using task seems to be a good enough alternative - even tho the view needs now be able to handle an optional object (and include some kind of loading screen).

For 1) Double init calls would not be a problem, as you would move the initialization of the object completely to .task

For 2) .task depends on .id and does some magical thinks like task cancellation. If the identity stays the same, the task will not run again for that view.

For 3) You should not force unwrap. The object will be nil when the view renders the first time, as task will run asynchronously (even if ran on the MainActor)