Composing views, managing state with ForEachStore

I am pretty new to TCA and have been rebuilding one of my apps while following along with the A tour of the Composable Architecture 1.0 series.

I've been able to completely reproduce one of the main features of my existing using the TCA but I'm fairly certain what I'm doing is incorrect. The overall apps purpose is to assist in the editing of a nested data structure. For simplicity, consider the following data structures:

// MODELS

struct A: Equatable {
    var bees: [B]
}

struct B: Identifiable, Equatable {
    var id: UUID
    var cees: [C]
}

struct C: Equatable {
    var someField = ""
}

As you can see, the A struct has an array of B structs which further have an array of C structs. I have three views, composed together, where the main state associated with the view corresponds to one of the data structures. That is, my AView uses a store (AFeature) with access to an A struct AND more notably has an array of BFeature.States for integration with a ForEachStore. The BView uses a store with access to a B struct and has an arrya of CFeature.States for integration with a ForEachStore. See the diagram below for a visualization of the above.

My issue is that with this set up, in my reducers, I end up writing somewhat redundant statements to mutate both the state's raw struct and the IdentifiedArrayOf<someFeature.State>. I've added delete examples in the diagram above. An add example follows:

            case .addCee:
                let newCee = C()
                state.b.cees.append(newCee)
                state.cees.append(CFeature.State(id: UUID(), c: newCee))
                return .none

This seems a little strange to me and isn't a scenario covered in ForEachStore documentation and I was unable to find a good example elsewhere.

Issue

This doesn't actually work, when appending a new B to A's array and appending a new BFeature.State, I can edit the B within the B view, but those edits never make it back to the B that the A struct is holding, it only changes the BFeature.State. This problem occurs at every level of the hierarchy. I'm not actually mutating A except for adding or deleting Bees from it.

Questions

  1. The A data structure won't be very large, should I consider modeling this with one state, instead of three? For example, there would probably never be more than 10 Bees and 10 Cees per B. So the data structure is not just not large, it's likely to be small.

  2. How should I conceptually think about problems like this, are there any resources you can point me to where this type of modelling is discussed?

  3. I'm open to any ideas here about this any advice, recommendations, tips, tricks are greatly appreciated. If it is important, I'm eventually going to persist this data on disk and or to a backend store so any forward thinking recommendations wtih that in mind would be great. What ya got?

References

Minimal Compilable Example

import SwiftUI
import ComposableArchitecture


// MODELS

struct A: Equatable {
    var bees: [B]
}

struct B: Identifiable, Equatable {
    var id: UUID
    var cees: [C]
}

struct C: Equatable {
    var someField = ""
}


// VIEWS

struct AView: View {
    
    let store: StoreOf<AFeature>
    var body: some View {
        WithViewStore(self.store, observe: { $0 }) { viewStore in
            List {
                ForEachStore(self.store.scope(state: \.bees, action: \.bees)) { bStore in
                    BView(store: bStore)
                }
            }
            
            Button("Add") {
                viewStore.send(.addBee)
            }
        }
        
    }
}

struct BView: View {
    
    let store: StoreOf<BFeature>
    
    var body: some View {
        WithViewStore(self.store, observe: { $0 }) { viewStore in
            
            List {
                ForEachStore(self.store.scope(state: \.cees, action: \.cees)) { ceeStore in
                    CView(store: ceeStore)
                }
            }
            .frame(height: calculateListHeight(count: viewStore.cees.count))
            
            Button("Add") {
                viewStore.send(.addCee)
            }
        }
        
    }
    
    func calculateListHeight(count: Int) -> CGFloat {
        return CGFloat(count) * 100
    }
}

struct CView: View {
    let store: StoreOf<CFeature>
    
    var body: some View {
        HStack {
            Text("someField")
        }
    }
}


// REDUCERS

@Reducer
struct AFeature {
    
    struct State: Equatable {
        var a: A
        var bees: IdentifiedArrayOf<BFeature.State> = []
    }
    
    enum Action {
        case addBee
        case bees(IdentifiedActionOf<BFeature>)
    }
    
    var body: some ReducerOf<Self> {
        Reduce { state, action in
            switch action {
                
            case .bees:
                return .none
                
            case .addBee:
                let newBee = B(id: UUID(), cees: [])
                state.bees.append(BFeature.State(id: UUID(), b: newBee))
                state.a.bees.append(newBee)
                return .none
                
            }
        }
        .forEach(\.bees, action: \.bees) {
            BFeature()
        }
    }
}


@Reducer
struct BFeature {
    struct State: Identifiable, Equatable {
        var id: UUID
        var b: B
        var cees: IdentifiedArrayOf<CFeature.State> = []
    }
    
    enum Action {
        case addCee
        case cees(IdentifiedActionOf<CFeature>)
    }
    
    var body: some ReducerOf<Self> {
        Reduce { state, action in
            switch action {
                
            case .cees:
                return .none
                
            case .addCee:
                let newCee = C()
                state.b.cees.append(newCee)
                state.cees.append(CFeature.State(id: UUID(), c: newCee))
                return .none
            }
        }
        .forEach(\.cees, action: \.cees) {
            CFeature()
        }
    }
}

@Reducer
struct CFeature {
    struct State: Identifiable, Equatable {
        var id: UUID
        var c: C
    }
    
    enum Action {
        
    }
    
    var body: some ReducerOf<Self> {
        Reduce { state, action in
            return .none
        }
    }
}


#Preview {
    AView(
        store: Store(initialState: AFeature.State(
            a: A(bees: []),
            bees: [])) {
                AFeature()
                    ._printChanges()
            }
        )
}

Hey @patlown !

Maybe, to avoid the duplicated model states among features, you could implement a MainFeature (AKA parent feature) to set up the object states - like A, B, and C models - and then once one of them changes, you would re-create your XFeature.State (AKA child feature) objects to get the new model state from the parent feature. Then you can rely on a single place of truth to manage (remove, append, etc) your model states.

I hope I could explain myself in a way to help you out.
Cheers!

Hi @patlown, if you are going to get tips from the discussion you linked to, be sure to read down to my comments here.

tldr; the "shared state" case study is most likely not what you want. You more likely want to model ubiquitous shared state as a dependency, rather than directly in a feature's state.

Thanks for the response. I think modelling this as a dependency is the way to go, hadn't got that far yet in the Tour series yet :wink: