Recursive reducer and sending actions through effects

Hello! Loving TCA so far. I'm currently using TCA in a side project at the company I work for, and it's been going really well. Hoping to convince others to get on board. One wall I'm hitting right now though is that I'm trying to use this recursive pattern as seen in the case studies:

04-HigherOrderReducers-Recursion.swift

extension Reducer {
  static func recurse(
    _ reducer: @escaping (Reducer, inout State, Action, Environment) -> Effect<Action, Never>
  ) -> Reducer {

    var `self`: Reducer!
    self = Reducer { state, action, environment in
      reducer(self, &state, action, environment)
    }
    return self
  }
}

That seems to work for basic actions, but as soon as I put an action in an Effect, it gets called on the "original" reducer. I've modified the case study to demonstrate. You can see below that I've added two actions to the nestedReducer:

let nestedReducer = Reducer<
    NestedState, NestedAction, NestedEnvironment
    >.recurse { `self`, state, action, environment in
        switch action {
        case .onAppear:
            return Effect(value: NestedAction.anAction)

        case .anAction:
            return .none

And onAppear is called in the NestedView:

struct NestedView: View {
    let store: Store<NestedState, NestedAction>

    var body: some View {
        WithViewStore(self.store.scope(state: { $0.description })) { viewStore in
            Form {
                Section(header: Text(template: readMe, .caption)) {

                    ForEachStore(
                        self.store.scope(state: { $0.children }, action: NestedAction.node(index:action:))
                    ) { childStore in
                        WithViewStore(childStore) { childViewStore in
                            HStack {
                                TextField(
                                    "Untitled",
                                    text: childViewStore.binding(get: { $0.description }, send: NestedAction.rename)
                                )

                                Spacer()

                                NavigationLink(
                                    destination: NestedView(store: childStore)
                                ) {
                                    Text("")
                                }
                            }
                        }
                    }
                    .onDelete { viewStore.send(.remove($0)) }
                }
            }
            .navigationBarTitle(viewStore.state.isEmpty ? "Untitled" : viewStore.state)
            .navigationBarItems(
                trailing: Button("Add row") { viewStore.send(.append) }
            )
        }.onAppear {
            ViewStore(self.store).send(.onAppear)
        }
    }
}

onAppear calls anAction through an Effect. If you run the case study, drill down to a child, and set a breakpoint at onAppear you'll see that the state is the expected state (the state of the child reducer). However, if you point a breakpoint at anAction, the state is not the state of the child. It is the root state. In fact, if you run the app and recursively add rows and navigate deeper and deeper, the breakpoint on anAction will always show you the root state. To me, that's unexpected. I would expect the action in the effect to come through the same reducer.

My use case for this is that I will be making an API call and displaying a list of children. Tapping on a child will load another instance of the same view, which has its own children and its own API call. This will keep going recursively. When I make an API call, I need to map it to an action through an Effect, which is where I'm having an issue. It's not calling back to the reducer that I'm expecting.

Am I missing something? Is there a different pattern that can be used in situations like this? Thanks in advance.

For anyone who might run into this same issue, I figured out how to get this to work. First, when calling the recursive self reducer, it returns an Effect that contains an Action:

  case let .node(index, action):
            return self.run(&state.children[index], action, environment) // This returns an effect that contains an Action

Since the root and all the children share the same Action type, the action from the effect only makes its way through the root. The child that expects the action never receives it. The solution to this is to map the returned action to the recursive action:

 case let .node(index, action):
            return self.run(&state.children[index], action, environment)
                .map { NestedAction.node(index: index, action: $0)}

Now the action will make its way to the appropriate child. You can call something like Effect(value: NestedAction.anAction), and it will work as expected.

The other piece to this is that the recurse function that is provided in the case study will only work for a simple reducer. In my case I am working on a more complex reducer that combines other reducers, and pulls back state changes. In order for recursion to work on the larger reducer, my reduce method looks like this:

extension Reducer {
    static func recurse(
        _ reducer: @escaping (Reducer) -> Reducer
    ) -> Reducer {
        var `self`: Reducer!
        self = Reducer { state, action, environment in
            reducer(self).run(&state, action, environment)
        }
        return self
    }
}

This allows you to wrap a larger, more complex reducer with recursion:

Reducer<State, Action, Environment>.recurse { `self` in
   Reducer.combine(
      Reducer { state, action, environment in 
         case .node(...):
            self.run(...)
      },
      anotherReducer.pullback(...)
   )
}

This works with identified arrays as well. You just need to make sure to run the reducer, get the mutated child state, and feed the child state back to the parent array.

@joshhaines Oh good find! We absolutely had a bug in this case study. We've added a silly side effect to it with a fix here:

In particular, this line fixes the issue:

Whenever invoking a recursive reducer you must also remember to map the effect to bundle it up again.

As for your example where you bundle up a combine and pullback in a recurse, couldn't you also do something like this:

Reducer.combine(
  .recurse { `self`, state, action, environment in
    case .node(...):
      self.run(...)
  },
  anotherReducer.pullback(...)
)

Or am I missing some context where anotherReducer uses self?

Thanks for updating the case study!

So in my case I was attempting to create a reducer that I could append to another reducer that added some remote data fetching capabilities. It's something that I'm not sure if I want to keep, or if it's a good pattern since I'm new to this sort of architecture, but here is what it looks like:

extension Reducer {
    public func remoteData<Value, Failure>(
        state: WritableKeyPath<State, RemoteDataState<Value, Failure>>,
        action: CasePath<Action, RemoteDataAction<Value, Failure>>,
        loadData: @escaping (State, Environment) -> () -> Effect<Value, Failure>
    ) -> Reducer {
        let remoteDataReducer = Reducer<RemoteDataState<Value, Failure>, RemoteDataAction<Value, Failure>, () -> Effect<Value, Failure>> { state, action, loadData in
            switch action {
            case .loadData:
                state.result = .loading
                return loadData()
                    .catchToEffect()
                    .map(RemoteDataAction.didLoadData)

            case let .didLoadData(.success(value)):
                state.result = .success(value)
                return .none

            case let .didLoadData(.failure(error)):
                state.result = .failure(error)
                return .none
            }
        }.pullback(state: state, action: action, environment: loadData)

        return .combine(
            Reducer { state, action, environment in
                remoteDataReducer.run(&state, action, (state, environment))
            },
            self
        )
    }
}

I'm passing an immutable copy of state to loadData so that the caller can configure an API call. This is in an attempt to separate some of this remote data loading from the main reducer.

The problem with that was that the immutable copy of state was never getting the recursive state, so I had to wrap the whole reducer with some recurse functionality.

Here's what it looks like to use the remoteData reducer:

.remoteData(
        state: \.remoteData,
        action: /ListAction.remoteData,
        loadData: { state, environment in
            {
                environment.doSomeAPICall(state.id)
                    .receive(on: environment.mainQueue)
                    .mapError { _ in MyError.error }
                    .eraseToEffect()
            }
    })

If I don't do any of that, then the recurse function from the case study is fine. I may rework or even remove that remote data loading reducer if it proves to be too much. I'd love to see some examples on higher-order reducers for loading remote data.

Terms of Service

Privacy Policy

Cookie Policy