View body gets called before binding value gets updated

Hi, please see the example code below. The app has two views: a list view and a detail view. Clicking an item in the list view goes to the detail view. The detail view contains a "Delete It" button. Clicking on the button crashes the app.

Below is my analysis of the root cause of the crash:

  1. When the "Delete It" button gets clicked, it removes the item from data model.

  2. Since the detail view accesses data model through @EnvironmentObject, the data model change triggers call of detail view's body.

  3. It turns out that the fooID binding in the detail view doesn't get updated at the time, and hence DataModel.get(id:) call crashes the app.

I didn't expect the crash, because I thought the fooID binding value in the detail view would get updated before the detail view's body gets called.

To put it in a more general way, the nature of the issue is that SwiftUI may call a view's body with a mix of up-to-date data model and stale binding value, which potentially can crash the app (unless we ignore invalid id in data model API code, but I don't think that's good idea because in my opinion this is an architecture issue that should be resolved on the caller side in the first place).

I have two questions:

  1. Is this behavior (a view's binding value doesn't get updated when the view's body get called if the view accesses data model through @EnvironmentObject) by design, or is it just a limitation in the current implementation?

  2. In practical apps an item A may contain its own value, as well as id of another item. As a result, to show the item A in detail view, we need to access data model to get B's value by its id. The typical way to access data model is by using @EnvironmentObject. But this issue makes it infeasible to do that. If so, what's the alternative approach?

Thanks for any suggestions.

import SwiftUI

struct Foo: Identifiable {
    var id: Int
    var value: Int
}

// Note that I use forced unwrapping in data model's APIs. The rationale: the caller of data model API should make sure it passes a valid id.

class DataModel: ObservableObject {
    @Published var foos: [Foo] = [Foo(id: 1, value: 1), Foo(id: 2, value: 2)]
    
    func get(_ id: Int) -> Foo {
        return foos.first(where: { $0.id == id })!
    }

    func remove(_ id: Int) {
        let index = foos.firstIndex(where: { $0.id == id })!
        foos.remove(at: index)
    }
}

struct ListView: View {
    @StateObject var dataModel = DataModel()

    var body: some View {
        NavigationView {
            List {
                ForEach($dataModel.foos) { $foo in
                    NavigationLink {
                        DetailView(fooID: $foo.id)
                    } label: {
                        Text("\(foo.value)")
                    }
                }
            }
        }
        .environmentObject(dataModel)
    }
}

struct DetailView: View {
    @EnvironmentObject var dataModel: DataModel
    // Note: I know in this simple example I can pass the entire Foo's value to the detail view and the issue would be gone. I pass Foo's id just to demonstrate the issue.
    @Binding var fooID: Int

    var body: some View {
        // The two print() calls are for debugging only.
        print(Self._printChanges())
        print(fooID)

        return VStack {
            Text("\(dataModel.get(fooID).value)")
            Button("Delete It") {
                dataModel.remove(fooID)
            }
        }
    }
}

struct ContentView: View {
    var body: some View {
        ListView()
    }
}

Replies

I have a hypothesis why it's so. If my understanding is correct, the behavior is a natural result of how @Binding works.

The official document doesn't describe how @Binding works. So I used to think it contains a reference pointing to a remote data backend. I didn't expect the binding wrapper variable itself changed. But when I used Self._printChanges() to debug view invalidation, I found the binding wrapper variable often changed. That puzzled me a lot in the past.

Now I have a completely different explanation on how it works. Let's put side how @Binding updates the remote data backend, we'll just focus on how it invalidates view. If my (new) understanding is correct, there is really no magic here, it invalidates view just because the caller of the view creates a new binding value when recreating the view. The explanation fits well with my observation that binding variable often changes.

Honestly speaking, I can hardly imagine it works like this. I don't understand why Apple doesn't add this to the doc. In my opinion, this is a very important piece of information that influences how people design SwiftUI apps. For one thing, this explains why @EnvironmentObject and @Binding have very different behavior in view invalidation and why they don't work well together.

Let's go back to my original question. In my original explanation, I thought binding wrapper variable contained a reference pointing to a data backend (BTW, I still think this part is true), so I expected it should return new value when data model changes. In my new explanation, what happens seems more complex. I can't really give a description of the details because I don't know. But the result is, when the view is invalidated by the change in @EnvironmentObject, due to the way how @Binding works, the mechanism to update binding wrapper variable, as well as its wrapped variable, isn't started yet. That's the reason we still read an old value in binding.

Does it have to be so? Well, I doubt it. In my opinion, view validation and view redraw (I mean, calling view's body) should be different phases. For example, it could be possible to just set a dirty flag to invalidate view and only recall view body after all data are synced.

The takeaway (note they are just my understanding):

  • @EnvironmentObject and @ObservableObject invalidate views through objectWillChange publisher. Invalidating view is implemented by recalling view's body. Since it's impossible to control the order of which receiver receives the data from a publisher, the order of which view's body getting called is arbitrary.

  • On the other hand, @Binding doesn't initiate view invalidation directly. It's a result of its caller's body call, plus the fact that binding wrapper variable changes (perhaps as a indicator of the backend data change). So it has ordering - it can only happens when its parent view (or ancestor view) is invalidated due to @EnvironmentObject, @ObservableObject, or @State change.

Anyway, with this plausible explanation I can continue to write my SwiftUI app (otherwise it would be like move in the dark).

While my above hypothesis might be right, there is another more important issue in my original question and code. The issue is I should avoid accessing data model in view rendering code. To put it in another way, pass value, instead of id. I knew this is a common practice in SwiftUI, but I had some difficulty in understanding it. It finally clicked when I read Paulw11's answer on SO.

I said it should avoid accessing data model in view rendering code in above answer. It's not correct. See my newest answer here.

The key point: SwiftUI is complex. It's unreliable to write code by assuming when/how view body gets recalled. As a result the data model API should be lenient. My original assumption that it's OK to use force unwrapping doesn't work. That's the reason why I observed various crashes but others don't.