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:

https://github.com/pointfreeco/swift-composable-architecture/pull/267

In particular, this line fixes the issue:

https://github.com/pointfreeco/swift-composable-architecture/pull/267/files#diff-e605cc2c3fc51f3ddeed256841b8b0acR61

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.

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.

Shouldn't this work?

case let .item(id, action):
 guard state.node[id: id] != nil else { return .none }
 
  run(&state.items[id: id]!, action, environment)
            .map { .item(id: id, action: $0) }

Having a generic high order reducer to manage collections of states that can contained other collections of states seems a pretty useful case study since nearly everything on iOS is about table view and collection view... @mbrandonw, @stephencelis

100%! Love TCA but I always end up writing boiler plate code for every single list of objects.

1 Like

100%! Love TCA but I always end up writing boiler plate code for every single list of objects.

I find myself spending more time trying to find a generic, less boilerplate-y approach to many things than the time I actually spend building the application. I love the functional programming style, and I want to continue to build with this architecture, but that's definitely a pain-point. I spent a long time investigating whether or not I could avoid all of the get {} set {} syncing of shared/child state, and eventually came to the conclusion that the get set boilerplate is probably the only way right now to synchronize shared state. It just feels fragile, and if I or someone else forgets a piece it could cause the application to not function properly.

I really hope Point-Free will do a series on building a full application with this architecture. I know there is the previous series where they are building out the actual architecture, but a lot has changed since then. I'd love to see a larger networking client, how that gets stored in the app state, how shared state is synchronized between all other states, etc. I'm not as confident in my approach to this architecture as I'd like to be. I would love to see the source code of a complex app built using TCA.

I have been starring at this code for too long and I am going to write some tests and hopefully figure it out.

I haven't been able to dive deeply into your examples, but it looks like you are using the recursive example from the case studies. Can you try my recurse reducer that I mention above, and wrap all reducers that you expect to be recursive? My guess is that you might be hitting the same issue I had where you need a larger, more complex reducer to be recursive, but the recursion is only happening on one level.

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
    }
}

Maybe in the meantime try not to use a generic for your itemCollection?

@DavidDens generics aren't the problem. If I try to do this without using recursion my code won't compile because the circular reference between my user reducer and my usersCollection reducer.