Unable to determine correct ForEachStore syntax when working with domain-specific state and action

I'm attempting to build a test application using the ToDo example merged with the sample code on presenting a modal view but it's not going well...

The problem relates to my attempts to separate out AppState into separate domains. The ToDo items (Patients in my example) form part of the global state but I've tried to reference these in a list-specific domain. I'm unable to figure out the correct syntax to plug into ForEachStore (see the PatientListView file below). The state: parameter is fine (or, at least it appears so at the moment) but I'm struggling with the action: parameter. If anyone can point me in the right direction that would be great!

Full code (excluding the App and ModalView files) as follows:

// MARK:- APP STATE
struct AppState: Equatable {
    var patients: [Patient] = []
    
    var modal: ModalState? = nil
    var isAddSheetPresented: Bool { self.modal != nil }
}

extension AppState {
    var patientList: PatientListState {
        get {
            PatientListState(patients: self.patients, modal: self.modal)
        }
        set {
            self.patients = newValue.patients
            self.modal = newValue.modal
        }
    }
    // TODO: PatientList needs, in essence, to be a non-optional version of ModalCompoundState as it needs the list of Patients and access to modalState so it can mutate it
    // when the add button is pressed. There does seem to be an element of duplication here... but roll with it for now and see if it can be refactored
    
    var modalCompound: ModalCompoundState? { // creates a compound state struct when the modal "add patient" view is displayed
        get {
            modal.map {
                .init(patients: self.patients, modal: $0)
            }
        }
        set {
            self.patients = newValue?.patients ?? self.patients
            self.modal = newValue?.modal
        }
    }
}

// MARK:- APP ACTION
enum AppAction: Equatable {
    case patientList(PatientListAction)
    case modal(ModalAction)
}


// MARK:- ENVIRONMENT
struct AppEnvironment {
    var uuid: () -> UUID
    
    static let live = AppEnvironment(uuid: UUID.init)
}


// MARK:- APP REDUCER
let appReducer: Reducer<AppState, AppAction, AppEnvironment> =
    Reducer.combine(
        patientListReducer
            .pullback(
                state: \.patientList,
                action: /AppAction.patientList,
                environment: { $0 }),
        modalReducer
            .optional()
            .pullback(
                state: \.modalCompound,
                action: /AppAction.modal,
                environment: { $0}
            )
    )


// MARK:- PATIENT LIST DOMAIN
struct PatientListState: Equatable {
    var patients: [Patient] = []
    var modal: ModalState? = nil
}

enum PatientListAction: Equatable {
    case patient(index: Int, action: PatientRowAction) // AppAction isolates Patient at a specific index
    case addButtonTapped
    case addSheetDismissed
}

let patientListReducer: Reducer<PatientListState, PatientListAction, AppEnvironment> = Reducer.combine(
    reducer,
    patientRowReducer.forEach(
        state: \.patients,
        action: /PatientListAction.patient(index:action:),
        environment: { _ in .live }
    )
)

let reducer = Reducer<PatientListState, PatientListAction, AppEnvironment> { state, action, environment in
    switch action {
    case .patient(_, _):
        return .none
        
    case .addButtonTapped:
        fatalError("Not implemented")
        return .none
        
    case .addSheetDismissed:
        fatalError("Not implemented")
        return .none
    }
}


// MARK:- VIEW
struct PatientListView: View {
    let store: Store<AppState, AppAction>
    
    var body: some View {
        NavigationView {
            WithViewStore(store.scope(
                state: \.patientList,
                action: AppAction.patientList
            )) { viewStore in
                List {
                    ForEachStore(
                        store.scope(
                            state: \.patientList.patients,
                            action: AppAction.patientList(.patient(index:action:)),
                            content: { PatientRowView(store: $0) }
                        )
                    )
                    
                    Text("-- End of list --")
                }
                .navigationBarTitle("Patients")
                .navigationBarItems(
                    trailing: Button("Add") {
                        viewStore.send(.addButtonTapped)
                    }
                )
            }
        }
    }
}

// MARK:- SUBVIEW
enum PatientRowAction: Equatable {
    case checkboxTapped
}

let patientRowReducer = Reducer<Patient, PatientRowAction, AppEnvironment> { state, action, environment in
    switch action {
    case .checkboxTapped:
        state.isComplete.toggle()
        return .none
    }
}

struct PatientRowEnvironment { }


// MARK:- VIEW
struct PatientRowView: View {
    let store: Store<Patient, PatientRowAction>
    
    var body: some View {
        WithViewStore(store) { patientViewStore in
            HStack {
                Text(patientViewStore.name)
                
                Spacer()
                
                Button(action: {
                    patientViewStore.send(.checkboxTapped)
                }) {
                    Image(systemName: patientViewStore.isComplete ? "checkmark.square" : "square")
                }
                .buttonStyle(PlainButtonStyle())
            }
            .foregroundColor(patientViewStore.isComplete ? .gray : nil)
        }
    }
}

Corrected a relatively obvious typo relating to the state.scope passed to the ForEachStore so fewer errors but still not correct:

ForEachStore(store.scope(state: \.patientList.patients,
                                           action: AppAction.patientList(PatientListAction.patient(index:action:))),
                                 content: { PatientRowView(store: $0) })

Error messages now stand at:
Cannot convert value of type 'AppAction' to expected argument type '((Int, PatientRowAction)) -> AppAction'
Cannot convert value of type '(Int, PatientRowAction) -> PatientListAction' to expected argument type 'PatientListAction'

After some additional tinkering, it made sense to scope the store to domain-specific state and actions which simplified matters (ForEachStore and ViewStore now only need a key path for state) and resolved the original issue= but (typically) pushed the problem elsewhere:

struct PatientListView: View {
//    let store: Store<AppState, AppAction>
    let store: Store<PatientListState, PatientListAction>
    
    var body: some View {
        NavigationView {
            WithViewStore(store) { viewStore in
                List {
                    ForEachStore(store.scope(
                                    state: \.patients,
                        action: PatientListAction.patient(index:action:)
                    ), content: PatientRowView.init(store:) )
                    
                    Text("-- End of list --")
                }
                .navigationBarTitle("Patients")
                .navigationBarItems(
                    trailing: Button("Add") {
                        viewStore.send(.addButtonTapped)
                    }
                )
                .sheet(isPresented: viewStore.binding(
                    get: \.isAddSheetPresented,
                    send: .addSheetDismissed
                )) {
                    IfLetStore(store.scope(state: \.modal, action: AppAction.modal)) { store in <<< ERROR
                        PatientModalView(store: store)
                    }
                }
            }
        }
    }
}

The error is: Cannot convert value of type '(ModalAction) -> AppAction' to expected argument type '(ModalAction) -> PatientListAction'.

This isn't unexpected considering I've changed the Action type but now I'd need to move the action into the PatientList domain which would muck up the appReducer... Getting very confused as I've never worked with functions, keyPaths, etc this extensively before and suspect the solution is so blindingly simple I can't see it....

OK - it's fixed!

The problem arose because I was trying to keep views in a flat hierarchy so essentially there was just one layer of views all deriving their state from AppState rather than taking the logical approach of deriving the state from their logical ancestor (parent). In this case, as the modal view was only ever presented by the PatientList, it made sense for PatientModalView to be a leaf/child of PatientList rather than a standalone view.
Moving state derivation removed the errors and reorganising the reducers (essentially lumping parent and child/leaf reducers together) did the trick with the app now functioning as expected. I hope it gets easier with practice because otherwise more complex scenarios will make my head explode!

Anyway, solution below which may prove useful for someone in a similar position:

// MARK:- APP STATE
struct AppState: Equatable {
    var patients: [Patient] = []
    
    var modal: ModalState? = nil
    var isAddSheetPresented: Bool { self.modal != nil }
}

extension AppState {
    var patientList: PatientListState {
        get {
            PatientListState(patients: self.patients, modal: self.modal)
        }
        set {
            self.patients = newValue.patients
            self.modal = newValue.modal
        }
    }
}

// MARK:- APP ACTION
enum AppAction: Equatable {
    case patientList(PatientListAction)
    case modal(ModalAction)
}


// MARK:- ENVIRONMENT
struct AppEnvironment {
    var uuid: () -> UUID
    
    static let live = AppEnvironment(uuid: UUID.init)
}


// MARK:- APP REDUCER
let appReducer: Reducer<AppState, AppAction, AppEnvironment> =
        patientListReducer
            .pullback(
                state: \.patientList,
                action: /AppAction.patientList,
                environment: { $0 })


// MARK:- PATIENT LIST DOMAIN
struct PatientListState: Equatable {
    var patients: [Patient] = []
    var modal: ModalState? = nil
    var isAddSheetPresented: Bool { self.modal != nil }
}

extension PatientListState {
    var modalCompound: ModalCompoundState? { // creates a compound state struct when the modal "add patient" view is displayed
        get {
            modal.map {
                .init(patients: self.patients, modal: $0)
            }
        }
        set {
            self.patients = newValue?.patients ?? self.patients
            self.modal = newValue?.modal
        }
    }
}


enum PatientListAction: Equatable {
    case patient(index: Int, action: PatientRowAction) // AppAction isolates Patient at a specific index
    case addButtonTapped
    case addSheetDismissed
    
    case modal(ModalAction)
}


let patientListReducer: Reducer<PatientListState, PatientListAction, AppEnvironment> = Reducer.combine(
    reducer,
    patientRowReducer.forEach(
        state: \.patients,
        action: /PatientListAction.patient(index:action:),
        environment: { _ in .live }
    ),
    modalReducer
        .optional()
        .pullback(
            state: \.modalCompound,
            action: /PatientListAction.modal,
            environment: { $0 }
        )
)

let reducer = Reducer<PatientListState, PatientListAction, AppEnvironment> { state, action, environment in
    switch action {
    case .patient(_, _):
        // defer to PatientRowReducer
        return .none
        
    case .addButtonTapped:
        state.modal = .init()
        return .none
        
    case .addSheetDismissed:
        state.modal = nil
        return .none
        
    case .modal(_):
        // defer to ModalReducer
        return .none
    }
}


// MARK:- VIEW
struct PatientListView: View {
    let store: Store<PatientListState, PatientListAction>
    
    var body: some View {
        NavigationView {
            WithViewStore(store) { viewStore in
                List {
                    ForEachStore(store.scope(
                                    state: \.patients,
                        action: PatientListAction.patient(index:action:)
                    ), content: PatientRowView.init(store:) )
                    
                    Text("-- End of list --")
                }
                .navigationBarTitle("Patients")
                .navigationBarItems(
                    trailing: Button("Add") {
                        viewStore.send(.addButtonTapped)
                    }
                )
                .sheet(isPresented: viewStore.binding(
                    get: \.isAddSheetPresented,
                    send: .addSheetDismissed
                )) {
                    IfLetStore(store.scope(
                        state: \.modal,
                        action: PatientListAction.modal
                    )) { store in
                        PatientModalView(store: store)
                    }
                }
            }
        }
    }
}


// MARK:- SUBVIEW
enum PatientRowAction: Equatable {
    case checkboxTapped
}

let patientRowReducer = Reducer<Patient, PatientRowAction, AppEnvironment> { state, action, environment in
    switch action {
    case .checkboxTapped:
        state.isComplete.toggle()
        return .none
    }
}

struct PatientRowEnvironment { }


// MARK:- VIEW
struct PatientRowView: View {
    let store: Store<Patient, PatientRowAction>
    
    var body: some View {
        WithViewStore(store) { patientViewStore in
            HStack {
                Text(patientViewStore.name)
                
                Spacer()
                
                Button(action: {
                    patientViewStore.send(.checkboxTapped)
                }) {
                    Image(systemName: patientViewStore.isComplete ? "checkmark.square" : "square")
                }
                .buttonStyle(PlainButtonStyle())
            }
            .foregroundColor(patientViewStore.isComplete ? .gray : nil)
        }
    }
}
Terms of Service

Privacy Policy

Cookie Policy