Recursive Reducer and Combine of child reducers

Hey everyone, I have been trying to use the example from High Order Reducers but also using combine for child reducers, the problem that I am facing is that once I am 1 level deep into the recurse, the combine does not work anymore.

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

        var `self`: Self!
        self = Self { state, action, environment in
            reducer(self, &state, action, environment)
        }
        return self
    }
    
    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
    }
}


struct FirstState: Equatable, Identifiable {
    let id = UUID()
    
    let isRecurseEnabled: Bool
    
    var state: IdentifiedArrayOf<FirstState> = []
    var recurseNavigation: Bool = false
    
    var secondState: SecondState? = nil
    var isShowingSecondView: Bool = false
}

indirect enum FirstAction: Equatable {
    case recurse(id: FirstState.ID, action: FirstAction)
    case setRecurseNavigation(isActive: Bool)
    case didTapRecurse
    
    case setSheetNavigation(isActive: Bool)
    case didTapButton
    case second(SecondAction)
}

let firstReducer = Reducer<
    FirstState,
    FirstAction,
    Void
>.combine(
    secondReducer
        .optional()
        .pullback(
            state: \.secondState,
            action: /FirstAction.second,
            environment: { _ in }
        ),
    .recurse { `self`, state, action, environment in
        switch action {
            case .didTapRecurse:
                state.state = [
                    FirstState(isRecurseEnabled: false)
                ]
                state.recurseNavigation = true
                return .none
                
            case .setRecurseNavigation(true):
                state.recurseNavigation = true
                return .none
                
            case .setRecurseNavigation(false):
                state.recurseNavigation = false
                state.state = []
                return .none
                
            case let .setSheetNavigation(isActive):
                state.isShowingSecondView = isActive
                return .none
                
            case .didTapButton:
                state.secondState = SecondState()
                state.isShowingSecondView = true
                return .none
                
            case let .second(action):
                print(action)
                return .none
                
            case let .recurse(id, _):
                return self.forEach(
                    state: \.state,
                    action: /FirstAction.recurse(id:action:),
                    environment: { $0 }
                )
                .run(&state, action, environment)
                .map { FirstAction.recurse(id: id, action: $0)}
        }
    }
)

struct FirstView: View {
    
    let store: Store<FirstState, FirstAction>
    
    var body: some View {
        WithViewStore(store) { viewStore in
            NavigationView {
                VStack {
                    Button(action: { viewStore.send(.didTapButton) }) {
                        Text("Tap for Second View")
                            .bold()
                    }
                    
                    if viewStore.isRecurseEnabled {
                        Button(action: { viewStore.send(.didTapRecurse) }) {
                            Text("Tap for Recurse View")
                                .bold()
                        }
                    }
                }
                
                .sheet(
                    isPresented: viewStore.binding(
                        get: \.isShowingSecondView,
                        send: FirstAction.setSheetNavigation(isActive:)
                    )
                ) {
                    IfLetStore(
                        store.scope(
                            state: \.secondState,
                            action: FirstAction.second
                        ),
                        then: SecondView.init(store:)
                    )
                }
                .sheet(
                    isPresented: viewStore.binding(
                        get: \.recurseNavigation,
                        send: FirstAction.setRecurseNavigation(isActive:)
                    )
                ) {
                    ForEachStore(
                        store.scope(
                            state: \.state,
                            action: FirstAction.recurse(id:action:)
                        )
                    ) { childStore in
                        FirstView(store: childStore)
                    }
                }
            }
        }
    }
}

struct SecondState: Equatable {
    
}

enum SecondAction: Equatable {
    case didTapButton
    case changedAction
}

let secondReducer = Reducer<SecondState, SecondAction, Void> { state, action, _ in
    switch action {
        case .didTapButton:
            return Effect(value: .changedAction)
        default:
            return .none
    }
}

struct SecondView: View {
    
    let store: Store<SecondState, SecondAction>
    
    var body: some View {
        WithViewStore(store) { viewStore in
            NavigationView {
                Button(action: { viewStore.send(.didTapButton) }) {
                    Text("Tap Me!!!")
                        .bold()
                }
            }
        }
    }
}

In this example if I tap the second screen button I will get the reducer of the second child ran and it will send an Effect to changedAction but if I am on a nested reducer this is no longer the case.

Here was my first pass at writing a generic reducer recurse function, maybe it will help. It took a little more legwork than I saw coming (I'm not sure I'm doing things in the optimal way either, but I was writing for clarity)...

extension Reducer {
	public func recurse(
		state toSubState: WritableKeyPath<State, State?>,
		action toSubAction: CasePath<Action, Action>
	) -> Self {
		Self { state, action, environment in
			// So: we have to run the effects, and `.merge` all their outputs
			// We have to run them in order, from deepest-nested, to root
			// We have to remember to re-embed the effects, though, when producing the final result!
			var nestedAction = action
			var nestedState = state
			var actionStates: [(Action, State)] = []

			// keep unwrapping action/states for as long as possible
			// TCA would print "reducer got an unexpected action when state was nil" if the actions
			// had a deeper nesting than the states, but this version will just silently ignore then
			while
				let nestedAction = toSubAction.extract(from: nestedAction),
				let nestedState = nestedState[keyPath: toSubState]
			{
				nestedAction = embeddedAction
				nestedState = embeddedState

				// this ensures that we stay with the deepest nesting at the head of the list
				actionStates.insert((embeddedAction, embeddedState), at: 0)
			}

			// Just run the reducer, if we don't have a recursively nested actions
			guard !actionsAtDepth.isEmpty else { return self.run(&state, action, environment) }

			// Grab off the top element, it will help provide the initial value for our `reduce`
			var (innerAction, innerState) = actionStates.removeFirst()
			let innerEffect = self.run(&innerState, innerAction, environment)

			// This takes an effect and embeds it 1 level deeper into the actions
			let deepenEffect: (Effect<Action, Never>) -> Effect<Action, Never> = { $0.map(toSubAction.embed(_:)) }

			// the reduce produces: the state to embed into current state, the actions returned thus far
			let initialResult: (State, [Effect<Action, Never>]) = (innerState, [innerEffect].map(deepenEffect))

			// REDUCE!
			let results = actionStates.reduce(initialResult) { partialResult, element in
				let (stateToEmbed, effects) = partialResult
				let actionToRun = element.0
				var mutableState = element.1
				mutableState[keyPath: toSubState] = stateToEmbed
				let totalEffects = effects + [self.run(&mutableState, actionToRun, environment)]
				return (mutableState, totalEffects.map(deepenEffect))
			}

			// I don't know if I can pull this into the reduce... because of the `inout`
			state[keyPath: toSubState] = results.0
			return .merge(results.1 + [self.run(&state, action, environment)])
		}
	}
}
1 Like