Reducer state keeps recomputing every time an outside state updates even with @ObservableState

While learning more and more about TCA, I'm perplexed on a problem where my Button view disappears on every state update that's outside of the ShareLinkButtonView. I figured the reason was due to the view recomputing/reinitializing every time an outside state updates and re-renders the entire view again.

The below code sends an action to Firebase to initialize and ultimately retrieve the ideaSnapshot object to be used for later. The button view should only be visible/usable when the object has been retrieved.

How do I get the RootStore's state to stay the way it is, i.e. not be recomputed unnecessarily? I'm using @ObservableState and I thought that would resolve it, but I don't think it's doing what I think it is?

ShareLinkButtonView:

public struct ShareLinkButtonView: View {
    @State private var showShareSheet: Bool = false
    let store: StoreOf<RootStore> = Store(initialState: RootStore.State()) {
        RootStore()
    }
    
    let eventId: String
    
    public init(eventId: String) {
        self.eventId = eventId
    }
    
    public var body: some View {
        WithPerceptionTracking {
            ZStack {
                if let ideaSnapshot = store.ideaSnapshot {
                    Button {
                        Haptics.shared.play(.soft)
                        self.showShareSheet.toggle()
                    } label: {
                        Image.Custom("share-box-arrow")
                            .resizable()
                            .aspectRatio(contentMode: .fill)
                            .frame(width: 30, height: 30)
                    }
                }
            }
            .onAppear {
                store.send(.initializeSnapshot(eventId))
            }
            .sheet(isPresented: $showShareSheet) {
                ShareSheet(activityItems: ["Hello world!"])
                    .presentationDetents([.fraction(0.65)])
            }
        }
    }
}

RootStore

@Reducer
struct RootStore {
    // MARK: Root state
    @ObservableState
    struct State {
        var ideaSnapshot: IdeaSnapshotModel?
    }
    
    // MARK: Root action
    enum Action {
        case initializeSnapshot(String)
        case getSnapshotByEventId(String)
        case getSnapshotByEventIdResponse(IdeaSnapshotModel?)
        case ideaSnapshot(IdeaSnapshotModel)
    }
    
    // MARK: Dependencies
    @Dependency(\.firebaseClient) var firebaseClient
    
    // MARK: Root reducer
    var body: some ReducerOf<Self> {
        Reduce { state, action in
            switch action {
            case .ideaSnapshot:
                return .none
                
            case .initializeSnapshot(let eventId):
                return handleInitializeSnapshot(state: &state, eventId)
                
            case .getSnapshotByEventId(let eventId):
                return handleGetSnapshotByEventId(state: &state, eventId)
                
            case .getSnapshotByEventIdResponse(let ideaSnapshot):
                return handleGetSnapshotByEventIdResponse(state: &state, ideaSnapshot)
            }
        }
    }
    
    // MARK: Action handlers
    func handleInitializeSnapshot(state: inout State, _ eventId: String) -> Effect<Action> {
        return .run { send in
            let isSuccessful = try await self.firebaseClient.initializeIdeaSnapshot(eventId)
            
            if isSuccessful {
                // Retrieve idea snapshot
                await send(.getSnapshotByEventId(eventId))
            } else {
                // TODO: Add retry logic here
            }
        }
    }
    
    func handleGetSnapshotByEventId(state: inout State, _ eventId: String) -> Effect<Action> {
        return .run { send in
            let ideaSnapshot = try await self.firebaseClient.getIdeaSnapshotByEventId(eventId)
            await send(.getSnapshotByEventIdResponse(ideaSnapshot))
        }
    }
    
    func handleGetSnapshotByEventIdResponse(state: inout State, _ ideaSnapshot: IdeaSnapshotModel?) -> Effect<Action> {
        state.ideaSnapshot = ideaSnapshot
        return .none
    }
}
1 Like

@kevintv789 It's hard to say without more code, but one thing that stands out is that your button is creating and throwing away a store each time the button is initialized, so I think whenever the parent re-renders it you're creating a whole new store. This is similar to if you were to create and throw away an @Observable or ObservableObject in vanilla SwiftUI.

So I think you'll want to hold it in @State to preserve it across renders:

-let store: StoreOf<RootStore> = Store(initialState: RootStore.State()) {
+@State var store: StoreOf<RootStore> = Store(initialState: RootStore.State()) {
     RootStore()
 }

Beyond that I think I'd need a full project reproducing the issue to troubleshoot further.

That did it! I had experienced this issue before but didn't know why it was unnecessarily throwing away the state. Thank you for an easy solution!