Value type 'State' cannot have a stored property that recursively contains it

Hi,

I'm struggling with recursive states and navigation between them. I saw 04-HigherOrderReducers-Recursion.swift but I'm little bit confused how to make it work.

Here is my situation:
For sake of simplicty I will leave View out, basically it is NavigatinoView with NavigationLink and some content.

I have TestFeature, from which I can navigate to BigFeature and from BigFeature I can navigate to DetailFeature, from which I can go to another BigFeature. So basically it should looks like TestFeature -> BigFeature -> DetailFeature -> BigFeature (with another data).

Here is code, but I'm getting Value type BigFeature.State cannot have a stored property that recursively contains it:

import Foundation
import ComposableArchitecture


struct TestWrapper: Equatable, Identifiable {
    var test: String
    let id: UUID
}

struct DetailWrapper: Equatable, Identifiable {
    var name: String
    let id: UUID
}

struct TestFeature : ReducerProtocol {
    struct State: Equatable {
        var loading: Bool = false
        var selection: Identified<TestWrapper.ID, BigFeature.State?>?
        var tests: IdentifiedArrayOf<TestWrapper> = []
    }
    
    enum Action: Equatable {
        case onAppear
        case onDisapper
    }
    
    func reduce(into state: inout State, action: Action) -> EffectTask<Action> {
        switch action {
        default: return .none
        }
    }
}

struct BigFeature : ReducerProtocol {
    struct State: Equatable {
        var loading: Bool = false
        var selection: Identified<DetailWrapper.ID, DetailFeature.State?>?
        var tests: IdentifiedArrayOf<DetailWrapper> = []
    }
    
    enum Action: Equatable {
        case onAppear
        case onDisapper
    }
    
    func reduce(into state: inout State, action: Action) -> EffectTask<Action> {
        switch action {
        default: return .none
        }
    }
}

struct DetailFeature : ReducerProtocol {
    struct State: Equatable {
        var loading: Bool = false
        var selection: Identified<TestWrapper.ID, BigFeature.State?>?
        var tests: IdentifiedArrayOf<TestWrapper> = []
    }
    
    enum Action: Equatable {
        case onAppear
        case onDisapper
    }
    
    func reduce(into state: inout State, action: Action) -> EffectTask<Action> {
        switch action {
        default: return .none
        }
    }
}

Is there any way how to make it works?
Thanks !

Hi @Vlados ! Maybe you've missed some implementations like hydrating your selection properties from your reducer. But I'd suggest not only hydrating states but implementing your features chain and Scope your feature reducers as you need.

Thanks for reply, can you be please more specific? I don't undestand "hydrating your selection properties". I just pasted simplified code without Views and Reducers. It is showing me error even without Reducers and View.

Sure, if you could please be more specific on your example, I'd be happy to give you a more detailed suggestion! Meanwhile, you can check the Scope reducer documentation page, hope it can help you!

Swift does not support recursive types like this. Even something as simple as this doesn't work:

struct Recursive {
  var recurse: Recursive?
  // Value type 'Recursive' cannot have a stored property that recursively contains it
}

What you need is to wrap the value in a reference type to add some indirection for breaking the infinite cycle, but then also taking care to make sure the reference type actually behaves like a value type.

One way to do this is to package up the value into an enum with a single case marked as indirect. You can even make it a property wrapper. Here is an example of how to do that.

2 Likes

Ok I'm sorry, I should be more specific on my example, here is updated code, hopefully it is more clarified.

Here is a TestFeature.swift:

struct TestWrapper: Equatable, Identifiable {
    var test: String
    let id: UUID
}

struct DetailWrapper: Equatable, Identifiable {
    var name: String
    let id: UUID
}

struct TestFeature : ReducerProtocol {
    struct State: Equatable {
        var loading: Bool = false
        var selection: Identified<TestWrapper.ID, BigFeature.State?>?
        var tests: IdentifiedArrayOf<TestWrapper> = []
    }
    
    enum Action: Equatable {
        case onAppear
        case onDisapper
        case setShowingBigFeature(selection: UUID?)
        case bigFeature(BigFeature.Action)
    }
    
    var body: some ReducerProtocol<State, Action> {
        Reduce { state, action in
            switch action {
            case .onAppear:
                // init data to tests
                return .none
            case let .setShowingBigFeature(selection: .some(id)):
                state.selection = Identified(BigFeature.State(), id: id)
                return .none
            case .setShowingBigFeature(selection: .none):
                state.selection = nil
                return .none
            default: return .none
            }
        }
        .ifLet(\.selection, action: /Action.bigFeature){
            EmptyReducer()
                .ifLet(\Identified<TestWrapper.ID, BigFeature.State?>.value, action: .self){
                    BigFeature()
                }
        }
    }
}

struct TestView: View {
    let store: StoreOf<TestFeature>
    
    var body: some View {
        WithViewStore(self.store, observe: { $0 }) { viewStore in
            NavigationView {
                Text("TestView")
                LazyVGrid(columns: Array(repeating: GridItem(.flexible(), spacing: 2), count: 2), spacing: 2) {
                    ForEach(viewStore.tests) { test in
                        NavigationLink(
                            destination: IfLetStore(
                                self.store.scope(
                                    state: \.selection?.value,
                                    action: TestFeature.Action.bigFeature
                                )
                            ) {
                                BigFeatureView(store: $0)
                            } else: {
                                ProgressView()
                            },
                            tag: test.id,
                            selection: viewStore.binding(
                                get: \.selection?.id,
                                send: TestFeature.Action.setShowingBigFeature(selection:)
                            )
                        ) {
                            Text("test \(test)")
                        }
                    }
                }
            }
        }
    }
}

Then here is "BigFeature":

struct BigFeature : ReducerProtocol {
    struct State: Equatable {
        var loading: Bool = false
        var selection: Identified<DetailWrapper.ID, DetailFeature.State?>?
        var bigFeatureTests: IdentifiedArrayOf<DetailWrapper> = []
    }
    
    enum Action: Equatable {
        case onAppear
        case onDisapper
        case setShowingDetailFeature(selection: UUID?)
        case detailFeature(DetailFeature.Action)
    }
    
    var body: some ReducerProtocol<State, Action> {
        Reduce { state, action in
            switch action {
            case .onAppear:
                // init data to bigFeatureTests
                return .none
            case let .setShowingDetailFeature(selection: .some(id)):
                state.selection = Identified(DetailFeature.State(), id: id)
                return .none
            case .setShowingDetailFeature(selection: .none):
                state.selection = nil
                return .none
            default: return .none
            }
        }
        .ifLet(\.selection, action: /Action.detailFeature){
            EmptyReducer()
                .ifLet(\Identified<DetailWrapper.ID, DetailFeature.State?>.value, action: .self){
                    DetailFeature()
                }
        }
    }
}

struct BigFeatureView: View {
    let store: StoreOf<BigFeature>
    
    var body: some View {
        WithViewStore(self.store, observe: { $0 }) { viewStore in
            NavigationView {
                Text("BigFeatureView")
                LazyVGrid(columns: Array(repeating: GridItem(.flexible(), spacing: 2), count: 1), spacing: 2) {
                    ForEach(viewStore.bigFeatureTests) { test in
                        NavigationLink(
                            destination: IfLetStore(
                                self.store.scope(
                                    state: \.selection?.value,
                                    action: BigFeature.Action.detailFeature
                                )
                            ) {
                                DetailFeatureView(store: $0)
                            } else: {
                                ProgressView()
                            },
                            tag: test.id,
                            selection: viewStore.binding(
                                get: \.selection?.id,
                                send: BigFeature.Action.setShowingDetailFeature(selection:)
                            )
                        ) {
                            Text("Big feature \(test)")
                        }
                    }
                }
            }
        }
    }
}

And finally DetailFeature which should navigate on "BigDetail but with another data":

struct DetailFeature : ReducerProtocol {
    struct State: Equatable {
        var loading: Bool = false
        var selection: Identified<TestWrapper.ID, BigFeature.State?>?
        var detailTests: IdentifiedArrayOf<TestWrapper> = []
    }
    
    indirect enum Action: Equatable {
        case onAppear
        case onDisapper
        case setShowingBigFeature(selection: UUID?)
        case bigFeature(BigFeature.Action)
    }
    
    var body: some ReducerProtocol<State, Action> {
        Reduce { state, action in
            switch action {
            case .onAppear:
                // init data to detailTests
                return .none
            case let .setShowingBigFeature(selection: .some(id)):
                state.selection = Identified(BigFeature.State(), id: id)
                return .none
            case .setShowingBigFeature(selection: .none):
                state.selection = nil
                return .none
            default: return .none
            }
        }
        .ifLet(\.selection, action: /Action.bigFeature){
            EmptyReducer()
                .ifLet(\Identified<TestWrapper.ID, BigFeature.State?>.value, action: .self){
                    BigFeature()
                }
        }
    }
}

struct DetailFeatureView: View {
    let store: StoreOf<DetailFeature>
    
    var body: some View {
        WithViewStore(self.store, observe: { $0 }) { viewStore in
            NavigationView {
                Text("DetailFeatureView")
                LazyVGrid(columns: Array(repeating: GridItem(.flexible(), spacing: 2), count: 1), spacing: 2) {
                    ForEach(viewStore.detailTests) { test in
                        NavigationLink(
                            destination: IfLetStore(
                                self.store.scope(
                                    state: \.selection?.value,
                                    action: DetailFeature.Action.bigFeature
                                )
                            ) {
                                BigFeatureView(store: $0)
                            } else: {
                                ProgressView()
                            },
                            tag: test.id,
                            selection: viewStore.binding(
                                get: \.selection?.id,
                                send: DetailFeature.Action.setShowingBigFeature(selection:)
                            )
                        ) {
                            Text("Detail feature \(test)")
                        }
                    }
                }
            }
        }
    }
}

Maybe I am doing it wrong, and I have some misunderstanding, but is there any way how can I do it?
Many Thanks ! :)

Did you try @Indirect? That fixes your problem. You will want to also conditionally conform it to Equatable.

1 Like

Jupey ! Yes it works, thanks to both of you ! :) Sorry, that I didn't try that.

1 Like

@vlados thank you! Now I got you, you were already using the ifLet, instead of Scope, within your reducer because your selection property is optional. I've did a Copy & Paste of all your files into a TCA demo project of mine here and also implemented the @Indirect property wrapper that @mbrandonw mentioned. I've just needed to conditionally comform the Indirect type with Equatable, when the generic type also conforms to Equatable indeed. It's compiling and working like a charm, really cool folx!

1 Like