Why can't SwiftUI state be changed in the middle of view updates?

I have arrived at a certain architectural solution for my SwiftUI code which is helped by, in certain situations, modifying the state while the body is being evaluated.

Of course, I am always open to realizing that a given solution may be creating difficulties precisely because it is fundamentally ill-advised. However, in this post I won't attempt to explain the details of my architecture or justify my reasoning regarding wanting to change the state in the middle of a view update. I just want to ask, why exactly is it prohibited? Is it not rather like normal recursion, which can of course produce infinite loops if done wrong but which is perfectly logically sound as long as the recursing function eventually stabilizes?

Answered by DTS Engineer in 794850022

@jeremy.a.bannister

Demystify SwiftUI and Data Essentials in SwiftUI WWDC sessions covers the topic extensively. I recommend you go over those resources.

To be as simple as one can be - you don't call it - it calls you. This means anything that triggers a view update in the form of a state, binding, or publisher follows an update-and-forget model unless you're observing changes from them to pass the results off to another dependency.

@jeremy.a.bannister

Demystify SwiftUI and Data Essentials in SwiftUI WWDC sessions covers the topic extensively. I recommend you go over those resources.

I had watched these videos when they were released and just re-watched both of them now - the only mention I see of anything related to my specific question is in the Data Essentials video when he talks briefly about the importance of avoiding expensive operations during body evaluation, because it can lead to dropped frames, etc. This still doesn’t help me understand why It results in “undefined behavior” to modify the state during body evaluation. Why can’t modifying the state just invalidate the current evaluation and trigger a new one? It is then my job as the programmer to make sure that that recursion settles down promptly, and to keep an eye on performance.

What @MobileTen said. Plus what you hinted yourself. Authorising this could create infinite loop or a lot of other side effects. As it is impossible to predict and handle all the cases, it is forbidden. In addition, that's fundamentally contrary to SwiftUI principle (we like it or not is not the question) which building principles have been explained by MobileTen. Contracticting the fundamental principles of an architecture would have been the wrong way to go.

However, depending on what you want to do exactly, there are several ways to achieve similar result.

Here are some examples:

struct ContentView: View {
  @State private var dummy = "OK"

  var body: some View {
      Text(dummy)
          .onAppear() {
              DispatchQueue.main.asyncAfter(deadline: .now() + 5) {
                  dummy = "changed after 5s"
              }
          }
      
      Button(action: {
          self.dummy = "Changed"
      }) {
          Text("Change")
      }
      
  }
}

To be clear, I'm not struggling to use standard SwiftUI tools. I'm striving for a highly elegant architecture that reduces the friction of developing UI to an even lower level than the already awesome level that one gets with SwiftUI out of the box, while simultaneously meeting certain additional requirements, for example aiming at portability of the maximum amount of interface-related code.

To that end I have come up with some very nice solutions, and one issue I'm facing is that I believe I have valid reasons for wanting, in some situations, modify the state during body evaluation, but even though my logic seems sound to me and the interface behaves exactly as it should, Xcode hits me with the standard warning:

Publishing changes from within view updates is not allowed, this will cause undefined behavior.

I've boiled down a dramatically simplified example of the type of situation in which I'm doing the disallowed thing of modifying the state during body evaluation:

@MainActor
final class StoredColors: ObservableObject {
    
    @Published
    var colors: [UUID: StandardHexadecimalColorCode] = [:]
    
    func createNewColorEntry() {
        colors[.generateRandom()] = .white
    }
}

@MainActor
final class EditableText: ObservableObject {
    
    @Published
    var values: [UUID: String] = [:]
}

struct ColorList: View {
    
    @ObservedObject var storedColors: StoredColors
    @StateObject var editableTextByColorID: EditableText = .init()
    
    var body: some View {
        VStack(alignment: .leading, spacing: 7) {
            
            ForEach(storedColors.colors.keys.asArray(), id: \.self) { colorID in
                textField(
                    forColorWithID: colorID
                )
                    .transition(.move(edge: .leading))
            }
            
            addItemButton(
                onPressed: {
                    withAnimation {
                        storedColors.createNewColorEntry()
                    }
                }
            )
        }
    }
    
    func textField(
        forColorWithID colorID: UUID
    ) -> some View {
        
        ensureThatThereIsEditableText(
            forColorWithID: colorID
        )
        
        return
            colorTextField(
                withBinding: textBinding(forColorWithID: colorID)
            )
    }
    
    @ViewBuilder
    func colorTextField(
        withBinding textBinding: Binding<String>?
    ) -> some View {
        
        if let textBinding {
            TextField(
                "#AA12FF",
                text: textBinding
            )
        }
    }
    
    func textBinding(
        forColorWithID colorID: UUID
    ) -> Binding<String>? {
        
        guard let currentValue = editableTextByColorID.values[colorID] else { return nil }
        
        return
            Binding(
                get: { editableTextByColorID.values[colorID] ?? currentValue },
                set: { newValue in
                    if editableTextByColorID.values[colorID] != nil {
                        editableTextByColorID.values[colorID] = newValue
                    }
                }
            )
    }
    
    func ensureThatThereIsEditableText(
        forColorWithID colorID: UUID
    ) {
        
        if editableTextByColorID.values[colorID] == nil {
            editableTextByColorID.values[colorID] = ""
        }
    }
}

Can someone tell me why exactly my approach here causes "undefined behavior"?

@DTS Engineer My highest hope is that SwiftUI could be evolved in a minor way such that I am allowed to opt in to having recursive view updates. As I mentioned, my interface behaves exactly as I want it to, the only apparent problem is that I get the runtime warning from SwiftUI in the console.

If this is impossible then I'm at least hoping that you could help me understand the details of why this is not an option.

Why can't SwiftUI state be changed in the middle of view updates?
 
 
Q