Skip to content

Child store invalidation issue #3717

@ddanilyuk

Description

@ddanilyuk

Description

Hi! I’ve run into a problem where the child views generated inside a ForEach stop responding to actions and state updates.

The issue began after upgrading to v1.19.1 and appears to be linked to this pull request.

I’ve created a minimal demo that reproduces the behavior. While this is the path I took to trigger the bug, it’s likely not the only way to surface it.

Video:
https://github.com/user-attachments/assets/a6edae3d-aa56-4339-a707-38cee4a8074a

Code:

import SwiftUI
import ComposableArchitecture

@Reducer
struct MainFeature {
    @ObservableState
    struct State {
        var childArray: IdentifiedArrayOf<ChildFeature.State>
        var childSolo: ChildFeature.State?
        
        init() {
            childSolo = ChildFeature.State(id: "1", count: 0)
            // It's important to start with non empty array.
            childArray = [ChildFeature.State(id: "1", count: 0)]
        }
    }
    
    enum Action {
        case setAnotherIDs
        case getBackToInitialIDsWithAnotherCount
        case resetToInitialIDs
        case childArray(IdentifiedActionOf<ChildFeature>)
        case childSolo(ChildFeature.Action)
    }
    
    var body: some ReducerOf<Self> {
        Reduce<State, Action> { state, action in
            switch action {
            case .setAnotherIDs:
                // Update ID
                state.childSolo = ChildFeature.State(id: "2", count: 0)
                state.childArray = [ChildFeature.State(id: "2", count: 0)]
                return .none
                
            case .getBackToInitialIDsWithAnotherCount:
                // Go back to the initial ID with a different count
                state.childSolo = ChildFeature.State(id: "1", count: 10)
                state.childArray = [ChildFeature.State(id: "1", count: 10)]
                return .none

            case .resetToInitialIDs:
                // Set the same ID as in the previous action, but with a count of 0
                // After this step, the child stores in childArray stopped working.
                state.childSolo = ChildFeature.State(id: "1", count: 0)
                state.childArray = [ChildFeature.State(id: "1", count: 0)]
                return .none
                
            case .childArray:
                return .none
                
            case .childSolo:
                return .none
            }
        }
        .ifLet(\.childSolo, action: \.childSolo) {
            ChildFeature()
        }
        .forEach(\.childArray, action: \.childArray) {
            ChildFeature()
        }
    }
}

@Reducer
struct ChildFeature {
    @ObservableState
    struct State: Identifiable {
        let id: String
        var count: Int
    }
    
    enum Action {
        case plus
        case minus
    }
    
    var body: some ReducerOf<Self> {
        Reduce { state, action in
            switch action {
            case .plus:
                state.count += 1
                return .none
                
            case .minus:
                state.count -= 1
                return .none
            }
        }
    }
}

struct ChildView: View {
    let store: StoreOf<ChildFeature>
    
    var body: some View {
        VStack {
            Text("id: " + store.id)
            
            HStack {
                Button("Minus") {
                    store.send(.minus)
                }
                
                Text(store.count.description)
                
                Button("Plus") {
                    store.send(.plus)
                }
            }
            .frame(height: 50)
            .buttonStyle(.borderedProminent)
        }
    }
}

struct MainView: View {
    let store: StoreOf<MainFeature>
    
    var body: some View {
        ScrollView {
            VStack {
                Button("setAnotherIDs") {
                    store.send(.setAnotherIDs)
                }
                
                Button("getBackToInitialIDsWithAnotherCount") {
                    store.send(.getBackToInitialIDsWithAnotherCount)
                }
                
                Button("resetToInitialIDs (not working)") {
                    store.send(.resetToInitialIDs)
                }
                
                Color.red.frame(height: 10)
                
                Text("Child solo:")
                if let childStore = store.scope(state: \.childSolo, action: \.childSolo) {
                    ChildView(store: childStore)
                }
                
                Color.red.frame(height: 10)
                
                Text("Child array:")
                ForEach(
                    store.scope(state: \.childArray, action: \.childArray)
                ) { childStore in
                    ChildView(store: childStore)
                }
            }
        }
    }
}

struct ContentView: View {
    @State var mainStore: StoreOf<MainFeature> = Store(initialState: MainFeature.State()) {
        MainFeature()
    }
    var body: some View {
        MainView(store: mainStore)
    }
}

Checklist

  • I have determined whether this bug is also reproducible in a vanilla SwiftUI project.
  • If possible, I've reproduced the issue using the main branch of this package.
  • This issue hasn't been addressed in an existing GitHub issue or discussion.

Expected behavior

No response

Actual behavior

No response

Reproducing project

No response

The Composable Architecture version information

1.19.1 - 1.20.2

Destination operating system

iOS 18

Xcode version information

16.4

Swift Compiler version information

swift-driver version: 1.120.5 Apple Swift version 6.1.2 (swiftlang-6.1.2.1.2 clang-1700.0.13.5)

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working due to a bug in the library.

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions