Unexpected Binding Behavior

I'm relatively new to Swift and TCA, so I'm not totally sure if what I'm seeing is expected behavior or not.

The core thing that I'm trying to do is to format a string that is typed in to a TextField and have the contents of that TextField respect the formatting. Specifically, I want to format the input to a TextField as a phone number.

My view looks more or less like

                TextField(
                    "Phone Number",
                    text: viewStore.binding(
                        get: {
                            return $0.phoneNumber
                        },
                        send: FormAction.phoneNumberChanged
                    )
                )

and my reducer looks like

    case let .phoneNumberChanged(phoneNumberStr):
        if phoneNumberStr.starts(with: "+1"), phoneNumberStr.count <= 3 {
            // Condition A
            return .none
        }
        
        state.phoneNumber = PartialFormatter().formatPartial(phoneNumberStr)
        return .none

However, when the action fires, I noticed that when Condition A occurs, the state displayed by the text field and the state of my application diverge. I would have expected the value that is displayed to be the get value of the viewStore.binding, but that does not seem to be the case.

Am I missing something about SwiftUI, Bindings, or TCA? Is this behavior expected?

I've included a complete pathological example, based on Binding Basics, of how to introduce this divergent state below. Notice that as you type in to the TextField, it displays what you type, but State remains unchanged. The behavior expected is that you should not be able to type into the TextField.

// Based on https://github.com/pointfreeco/swift-composable-architecture/blob/master/Examples/CaseStudies/SwiftUICaseStudies/01-GettingStarted-Bindings-Basics.swift

import ComposableArchitecture
import SwiftUI

// The state for this screen holds a bunch of values that will drive
struct BindingBasicsState: Equatable {
  var text = ""
}

enum BindingBasicsAction {
  case textChange(String)
}

struct BindingBasicsEnvironment {}

let bindingBasicsReducer = Reducer<
  BindingBasicsState, BindingBasicsAction, BindingBasicsEnvironment
> {
  state, action, _ in
  switch action {
  case let .textChange(text):
//    state.text = text
    return .none
  }
}

struct BindingBasicsView: View {
  let store: Store<BindingBasicsState, BindingBasicsAction>

  var body: some View {
    WithViewStore(self.store) { viewStore in
      Form {
        Section(header: Text("Example")) {
          HStack {
            TextField(
              "Type here",
              text: viewStore.binding(get: { $0.text }, send: BindingBasicsAction.textChange)
            )
            .disableAutocorrection(true)
            Text(viewStore.text)
          }
        }
      }
    }
    .navigationBarTitle("Bindings basics")
  }
}

struct BindingBasicsView_Previews: PreviewProvider {
  static var previews: some View {
    NavigationView {
      BindingBasicsView(
        store: Store(
          initialState: BindingBasicsState(),
          reducer: bindingBasicsReducer,
          environment: BindingBasicsEnvironment()
        )
      )
    }
  }
}

This is expected behaviour from TCA.
This is how it works on Vanilla SwiftUI.

If you want to test it, here I adapted the snippet to use Vanilla.

struct BindingBasicsView: View {
    @State var text: String = ""
    
    var body: some View {
        Form {
            Section(header: Text("Example")) {
                HStack {
                    TextField(
                        "Type here",
                        text: Binding(
                            get: { text },
                            set: { _ in
                                //Notice that we do nothing with new text coming in
                            }
                        )
                    )
                    .disableAutocorrection(true)
                    Text(text)
                }
            }
        }
        .navigationBarTitle("Bindings basics")
    }
}

struct BindingBasicsView_Previews: PreviewProvider {
    static var previews: some View {
        NavigationView {
            BindingBasicsView()
        }
    }
}

So, basically if text field is active first responder doesn't read the state. As soon as you resign from responder(press enter for example), it will read state and empty the text field.

To fix your issue with phone number, you just need to put
state.phoneNumber = phoneNumberStr
when Condition A occurs.

N.B. get: \.phoneNumber looks way nicer than

get: {
                            return $0.phoneNumber
                        },

I’m dealing with the exact same issue. The most disappointing part is, I did have this working previously! I think something changed in the latest iOS/SwiftUI version. Before now, it did exactly what I wanted: as the user typed, the text change would trigger an action in the store, the input string would be formatted, the value in the state updated, and the binding would set the formatted value on the textfield immediately. I had a number formatter for example, and if you typed a letter or punctuation, it would be immediately stripped out and wouldn’t even appear. And the comma separators would be inserted in the fly as you typed. It was really nice :confused: Now, without any changes on my end, the formatting only kicks in after resigning first responder. I noticed TextField also has an initializer which allows you to provide a formatter. Going to play around with that and see if I can get it to play nicely with TCA.