Question about ViewStore.Publisher removing duplicates

Hi, I'm trying to rewrite some parts of my UIKit app using TCA (and UIKit for now).

I have a TableView implemented with TCA and when user taps on cell, app needs to open non-TCA screen. This is basically how I am trying to present new ViewController:

struct LakeBookState: Equatable {
    var lakeToOpen: Lake // where Lake is a class, not a struct
    (...)

Reducer { state, action, environment in
    // set nil before every cicle to avoid opening multiple times
    state.lakeToOpen = nil
   
    switch action {
    case .tappedLake(let lake):
        // set lakeToOpen when user taps on cell
        state.lakeToOpen: lake
    (...)

final class LakeListVC: UIViewController {

    override func viewDidLoad() {
        super.viewDidLoad()
        
        viewStore.publisher.lakeToOpen
            // filter nil values
            .compactMap { $0 }
            .sink { [weak self] lake in
                // push LakeVC with lake user just tapped in TableView
                self?.navigationController?.pushViewController(LakeVC()...
            }
            .store(in: &cancellables)
        (...)

It seems viewStore.publisher.lakeToOpen removes duplicates.
I also found, that during initialisation of ViewStore I can pass a closure for removeDuplicates, but even if I simply return false, the publisher still skips duplicates.

Now I could probably wrap my Lake in some wrapped object with unique ID each time, but maybe there is smarter solution?

You need to elaborate on the expected behavior and the observed behavior in your program. You also need to post much more example code.

Why didn't you include the code where you initialize the ViewStore?

How does this code fit in with the rest of the app? It's not used anywhere in the snippet you posted.

Hi, sorry for not providing enough code. I've made quick demo project that compiles and has my described issue:

import UIKit
import ComposableArchitecture
import Combine

final class Lake: Equatable {
    
    init(title: String, id: Int) {
        self.title = title
        self.id = id
    }
    
    let title: String
    let id: Int
    
    static func == (lhs: Lake, rhs: Lake) -> Bool {
        lhs.id == rhs.id && lhs.title == rhs.title
    }
}

struct LakeBookState: Equatable {
    var lakes: [Lake] = [Lake(title: "One", id: 1), Lake(title: "Two", id: 2),
                         Lake(title: "Three", id: 3), Lake(title: "Four", id: 4),
                         Lake(title: "Five", id: 5), Lake(title: "Six", id: 6), ]
    
    var lakeToOpen: Lake?
}

enum LakeBookAction: Equatable {
    case tappedLake(IndexPath)
}

struct LakeBookEnvironment { }

let lakeBookReducer = Reducer<LakeBookState, LakeBookAction, LakeBookEnvironment> { state, action, environment in
    state.lakeToOpen = nil
    
    switch action {
    case .tappedLake(let indexPath):
        state.lakeToOpen = state.lakes[indexPath.row]
        return .none
    }
}

final class ViewController: UIViewController {
    
    let store: Store<LakeBookState, LakeBookAction>
    let viewStore: ViewStore<LakeBookState, LakeBookAction>
    var cancellables: Set<AnyCancellable> = []
        
    init(store: Store<LakeBookState, LakeBookAction>) {
        self.store = store
       // removeDuplicates closure does nothing in my case
        self.viewStore = ViewStore(store, removeDuplicates: { _, _ in
            return false
        })
        super.init(nibName: nil, bundle: nil)
    }
    
    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
    
    private lazy var tableView: UITableView = {
        let tv = UITableView()
        tv.translatesAutoresizingMaskIntoConstraints = false
        tv.dataSource = self
        tv.delegate = self
        tv.backgroundColor = .white
        tv.allowsSelection = true
        tv.isUserInteractionEnabled = true
        tv.separatorStyle = .singleLine
        tv.delaysContentTouches = false
        return tv
    }()
    
    override func viewDidLoad() {
        super.viewDidLoad()
        
        view.addSubview(tableView)
        NSLayoutConstraint.activate([
            tableView.topAnchor.constraint(equalTo: view.topAnchor),
            tableView.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor),
            tableView.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor),
            tableView.bottomAnchor.constraint(equalTo: view.bottomAnchor)
        ])
        
        // this should open user selected lake 
        viewStore.publisher.lakeToOpen
            .compactMap { $0 }
            .sink { [weak self] lake in
                self?.navigationController?.pushViewController(LakeViewViewController(), animated: true)
            }
            .store(in: &cancellables)
    }
}

extension ViewController: UITableViewDelegate, UITableViewDataSource {
    func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        UITableViewCell()
    }
    
    func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        6
    }
    
    func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat {
        60
    }
    
    func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
        // here I am handling cell taps
        viewStore.send(.tappedLake(indexPath))
    }
}

And SceneDelegate:

class SceneDelegate: UIResponder, UIWindowSceneDelegate {

    var window: UIWindow?

    func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
        
        guard let windowScene = (scene as? UIWindowScene) else { return }
        
        let vc = ViewController(store: Store(initialState: LakeBookState(),
                                             reducer: lakeBookReducer,
                                             environment: LakeBookEnvironment()))
        
        let nav = UINavigationController(rootViewController: vc)
        UIApplication.shared.windows.first?.rootViewController = nav
        UIApplication.shared.windows.first?.makeKeyAndVisible()
        
        self.window = UIWindow(windowScene: windowScene)

        self.window?.rootViewController = nav
        self.window?.makeKeyAndVisible()
    }
(...)

So my issue is that if user taps same cell twice in a row (basically if we call viewStore.send(.tappedLake(indexPath)) for the same Lake) then viewStore.publisher.lakeToOpen will ignore second event

The way Swift mutation is observed is scoped. When you pass a mutable variable to an inout function and observe that variable (with didSet, for example), you will only get notified a single time, even if that inout function manipulates that state in several steps.

This means that even though you nil out lakeToOpen here:

state.lakeToOpen = nil

This mutation is never observed when .tappedLake immediately replaces the value later on:

state.lakeToOpen = lake

There are a few ways to work around this, but what's perhaps important to know is that the Composable Architecture, like SwiftUI, embraces the idea that state should be "descriptive": given a value of app state, the store should be able to render your app. This means that state needs to change for it to render in a different way. This means that you should not be able to send a tappedLake action when lakeToOpen is non-nil, since if it's non-nil that means it should be presented.

So ideally, you can introduce a new action that can nil out the lakeToOpen when that view is dismissed, by sending dismissLake to the store. You could even rename the lakeToOpen state to presentedLake.

case .dismissLake:
  state.presentedLake = nil
  return .none

case let .tappedLake(lake):
  state.presentedLake = lake
  return .none
1 Like

Got it, thanks!