Responding to `FooClient.Action` regardless of which view generates `Effect`s emitting them

Hello, I have a struct FooClient with an enum FooClient.Action.

I have var fooClient: FooClient in my top level Environment and I pass it down to a few different view's focused environments as well. Those views have their own subtype struct State and enum Action, each of which as a case fooClient(FooClient.Action).

If I want to make a reducer that can access top level App.State and can react to FooClient.Action regardless of which view originates them, I have to do something like this:

switch action {
case .fooClient(let action),
  .aView(.fooClient(let action)),
  .otherView(.fooClient(let action)): // and so on
  // update `App.State`
  return .concatenate(
    environment.barClient.doBarThing().map(App.Action.barClient),
    environment.bazClient.doBazThing().map(App.Action.bazClient),
  )
...
}

This works, but it's unfortunate I have to explicit list each place that can generate a FooClient.Action with a separate pattern match in the case.

If I don't do that I can combine the reducer multiple times, pulled back to each path so it's scoped directly to FooClient.Action, but then in order to be able to use barClient and bazClient I have to add case barClient(BarClient.Action) and case bazClient(BazClient.Action) to FooClient.Action so that that pulled-back reducer can use effects from those other clients.

The way Actions are currently structured as a hierarchy I don't see a way around this, but it would be nice if it was possible to respond to all FooClient.Action without having to enumerate them either inside the switch or inside the combine.

Hey Joe,

we haven't talked about this at all yet and don't have any documentation on it, and there are a few ways to approach this. One interesting way is to create a higher-order reducer that can allow sharing actions from one reducer to another.

For example, this:

extension Reducer {
  func resending<Value>(
    _ case: @escaping (Value) -> Action,
    to embed: @escaping (Value) -> Action
  ) -> Self {
    .combine(
      self,
      .init { state, action, _ in
        if let value = CasePath.case(`case`).extract(from: action) {
          return Effect(value: embed(value))
        } else {
          return .none
        }
      }
    )
  }
}

You can tack that onto an existing reducer to make any of its action be automatically re-sent to another reducer:

parentReducer
  .resending(ParentAction.fooClient, to: { ParentAction.child1(.fooClient($0)) })
  .resending(ParentAction.fooClient, to: { ParentAction.child2(.fooClient($0)) })
...

I'd be curious how that fits your use case.

4 Likes

What would be the right way to make resending works with optional reducer ?

I have a solution, but it feels more like a hack than anything else :

func optionalResending<Value, LocalState>(
        _ case: @escaping (Value) -> Action,
        to embed: @escaping (Value) -> Action,
        on targetedState: KeyPath<State, LocalState?>
    ) -> Self {
        .combine(
            self,
            .init { state, action, _ in
                guard let value = CasePath.case(`case`).extract(from: action) else { return .none }
                if state[keyPath: targetedState] == nil {
                    return .none
                } else {
                    return Effect(value: embed(value))
                }
            }
        )
    }

I need to tell the resending function the state that can be optional.

2 Likes

@mbrandonw @Dragna since the CasePath 0.3.0 release (Release 0.3.0 · pointfreeco/swift-case-paths · GitHub) this code is no longer working. Why is that?

Hi @moebius, this is due to the (breaking) change in Breaking Change: Simplify Reflection by stephencelis · Pull Request #32 · pointfreeco/swift-case-paths · GitHub unfortunately. This makes it so that we do not automatically derive extract functions from things like { ParentAction.child1(.fooClient($0)) }, i.e. nested enums. We didn't realize many people were relying on this behavior.

A workaround is to make resending take a case path instead of an embed, and then use case path composition:

(/ParentAction.child1).appending(path: /Action.fooClient)

// or 

/ParentAction.child1 .. /Action.fooClient

If you want to discuss whether this breaking change was the right thing for us to do you could start a discussion on the TCA repo so that we could discuss more options and get more feedback from others.

Hi @mbrandonw I just refactored my code into a "resending" reducer, which is probably even better for performance, since I have many resending actions. So I'm fine with this braking change.