OnChange BindingReducer Usage

I just started working through the most recent TCA videos going through the Standups app, I have been rebuilding an app I made in Vanilla SwiftUI in TCA as I learn from the series.

I am building a form using text fields and I am unsure if I am using the onChange modifier against the BindingReducer correctly. I've created a minimal example to show what I've come up with.

I have the following questions:

  • Is this the correct usage of the BindingReducer onChange modifier? It seems a little weird to process the logic as an effect when it is quick logic that isn't a side effect.

  • This is just one field, my form will have at least three such fields with additional pre-processing behavior like this, is this the correct approach?

example:

struct OnChangeExampleView: View {
    let store: StoreOf<OnChangeExampleFeature>
    
    var body: some View {
        WithViewStore(self.store, observe: { $0 }) { viewStore in
            TextField("myField", text: viewStore.$myField)
            
            Text("\(viewStore.myFieldDouble)")
        }
    }
}

@Reducer
struct OnChangeExampleFeature: Reducer {
    
    struct State: Equatable {
        @BindingState var myField = ""
        var myFieldDouble: Double
    }
    
    enum Action: BindableAction {
        case binding(BindingAction<State>)
        case setMyFieldDouble(Double)
    }
    
    var body: some ReducerOf<Self> {
        BindingReducer()
            .onChange(of: \.myField) { oldValue, newValue in
                Reduce { state, action in
                        .run { send in
                            if let value = Double(newValue.filter("0123456789.".contains)) {
                                await send(.setMyFieldDouble(value))
                            }
                        }
                }
            }
        
        Reduce { state, action in
            switch action {
                
            case .binding:
                return .none
                
            case let .setMyFieldDouble(val):
                state.myFieldDouble = val
                return .none
            }
        }
    }
}

#Preview {
    OnChangeExampleView(
        store: Store(initialState: OnChangeExampleFeature.State(myFieldDouble: 0.0)) {
            OnChangeExampleFeature()
                ._printChanges()
        }
    )
}

Hey @patlown !

You can also go with like:

var body: some ReducerOf<Self> {
    BindingReducer()

    Reduce { state, action in
        switch action {
            
        case .binding(\.$myField):
              guard let value = Double(state.myField.filter("0123456789.".contains))
              else { return .none }

              return .run { send in
                  await send(.setMyFieldDouble(value)
              }

        case .binding:
            return .none

        case let .setMyFieldDouble(val):
            state.myFieldDouble = val
            return .none
        }
    }
}

Generally we advise against this.

Any reason not to mutate the state directly?

BindingReducer()
  .onChange(of: \.myField) { oldValue, newValue in
    Reduce { state, action in
      if let value = Double(newValue.filter("0123456789.".contains)) {
        state.myFieldDouble = value
      }
      return .none
    }
  }

Per Gabriel's note above, though, you can also switch directly on the binding case of a particular field. Reducer.onChange is more when you want to detect a small change in a larger structure.

Gabriel's version again may work better, but if you are working with a nested structure you can definitely chain onChange(of:) instead.

Also, sometimes you wanna add some debounce, when binding changes, so maybe returning an effect can be helpful:


enum CancelID {
        case debounce
    }

var body: some ReducerOf<Self> {
    BindingReducer()

    Reduce { state, action in
        switch action {
            
        case .binding(\.$myField):
              guard let value = Double(state.myField.filter("0123456789.".contains))
              else { return .none }

              return .run { send in
                  await send(.setMyFieldDouble(value)
              }
              .debounce(id: CancelID.debounce, for: .seconds(1.0), scheduler: mainQueue)

        case .binding:
            return .none

        case let .setMyFieldDouble(val):
            state.myFieldDouble = val
            return .none
        }
    }
}
``