Removing rx.tap does not mean going back to scattered target-action code. It means being more deliberate about where event wiring lives.

For simple UIKit actions, UIAction is usually the cleanest replacement.

If a button only:

  • dismisses a screen
  • saves a form
  • opens a picker
  • triggers one local method

adding a full publisher chain does not improve the code. It just changes the syntax.

What matters is keeping action setup in one place. If button behavior is configured together during setup, the code stays readable. If event wiring gets spread across lifecycle methods, helper extensions, and random sink chains, the migration got harder, not better.

CombineCocoa is useful when taps are part of real stream logic.

If the event is:

  • merged with other publishers
  • throttled
  • transformed
  • coordinated with async work

a publisher is a good fit. If not, native UIKit is often enough.

The mistake is assuming every Rx event must stay reactive after the migration. That is how teams end up rebuilding rx.tap everywhere, even when the original behavior was just “call one method on tap.”

The practical rule is straightforward.

  • Use UIAction for local UI actions.
  • Use CombineCocoa when the tap genuinely participates in a Combine pipeline.

The migration is successful when the intent is clearer than before, not when every tap became a publisher.

Simple Example

import UIKit

final class SettingsViewController: UIViewController {
    private let saveButton = UIButton(type: .system)

    override func viewDidLoad() {
        super.viewDidLoad()

        saveButton.addAction(UIAction { [weak self] _ in
            self?.save()
        }, for: .touchUpInside)
    }

    private func save() {
        // Save settings.
    }
}

This is a local UI action, so UIAction is enough.

There is no need to rebuild a full reactive chain if the tap only calls one method.