Store publisher objects are out of sync with Store properties

Hi, I have this weird issue where if UIViewController listens to value via store.publisher it differs from value if access it directly from store

Here's the issue:

viewStore.publisher.sessions
    .sink { [weak self] sessions in
        if sessions.count != self!.viewStore.sessions.count {
            // This gets called sometimes, why and how to solve it?
        }
    }
  .store(in: &cancellables)

My issue, that based on new sessions I need to reload tableView, but since sometimes viewStore.sessions differs from latest version, I don't get desired effect.

I am really new with Combine and Composable Architecture, it might be I am doing something silly, so here's small snippet:

func showSessions() {
    let env = Environment(mainQueue: .main,
                          fetchSessions: {
                            UserData.sharedData.sessions
                                .getAll() // returns Future<[Session], Never>
                                .eraseToEffect()
                          })
    
    let viewController = SessionVC(store: Store(initialState: SessoinState(),
                                               reducer: sessionReducer,
                                               environment: env))
    
    navigationController?.pushViewController(lakeBokVC, animated: true)
}

struct SessionState: Equatable {
    var sessions: [SonarSession] = []
}

enum SessionAction: Equatable {
    case viewDidLoad
    case fetchedSessions(Result<[SonarSession], Never>)
}

struct Environment {
    var mainQueue: AnySchedulerOf<DispatchQueue>
    var fetchSessions: () -> Effect<[SonarSession], Never>
}

let lakeBookReducer = Reducer<SessionState, SessionAction, Environment> { state, action, environment in
    switch action {
    case .viewDidLoad:
        return environment.fetchSessions()
            .receive(on: environment.mainQueue)
            .catchToEffect()
            .map(SessionAction.fetchedSessions)

    case let .fetchedSessions(.success(sessions)):
        state.sessions = sessions
        return .none        
    }
}

final class SessionVC: UIViewController {
    (...)
    override func viewDidLoad() {
        super.viewDidLoad()
        
        viewStore.publisher.sessions
            .sink { [weak self] sessions in
                if sessions.count != self!.viewStore.sessions.count {
                    // This gets called sometimes, why and how to solve it?
                }
            }
          .store(in: &cancellables)
    }
}

Hi @JustasL, the issue is that viewStore.publisher emits when the willSet of state is triggered. So, even though viewStore.publisher.sink { } is handed the freshest value, the view store technically still holds onto the previous value.

The reason it emits on willSet is because that's how SwiftUI works. In retrospect it may have been a better idea to have a separate idea for ViewStore that is better suited for UIKit, but alas we never explored that.

The solution to the problem is to never refer to viewStore.state from inside a viewStore.publisher.sink { }. You should only access what is handed to that closure, as it is the freshest data. We'll try to think of ways to mitigate this for UIKit in the future :slightly_smiling_face:.

Hope that helps.

2 Likes

Thanks to @maximkrouk this behavior will be more straightforward in the next release: Change ViewStore state update behavior by maximkrouk · Pull Request #634 · pointfreeco/swift-composable-architecture · GitHub

It makes sense to not adopt SwiftUI-specific behavior outside of SwiftUI :grinning_face_with_smiling_eyes:

2 Likes