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.

2 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.

Terms of Service

Privacy Policy

Cookie Policy