RxSwift to Combine is usually a behavior migration

RxSwift to Combine looks like a syntax migration.

In a real iOS app, it is usually a behavior migration.

The app can compile. The UI can look correct. The refactor can even feel clean in review. But if lifecycle, cancellation, timing, or state replay changed, the product behavior changed too.

That is where the dangerous bugs hide.

The migration is not the hard part. The behavior is.

Replacing operators is straightforward.

Replacing behavior safely is not.

Reactive code often sits in places where small changes have visible consequences: screen loading, refresh events, analytics, reusable UI, pagination, and state updates.

A migration from RxSwift to Combine should preserve more than the final UI. It should preserve when events happen, how long subscriptions live, whether state is replayed, and which thread or scheduler delivers updates.

If those contracts change, the code is not just refactored. It is behaviorally different.

Cancellation is product behavior

DisposeBag and AnyCancellable solve similar problems, but the ownership model deserves careful review.

A DisposeBag often communicates a lifecycle boundary. When the bag is replaced, subscriptions die. In Combine, the same idea usually becomes a Set<AnyCancellable>.

That sounds simple, but the important question is:

Who owns this subscription?

A subscription owned by the whole screen lifetime behaves differently from one owned by the visible screen lifetime. A subscription owned by a reusable view behaves differently from one owned by a parent model.

Move that boundary accidentally, and you can get duplicate events, stale UI updates, or missing callbacks after navigation.

State replay can change screen behavior

BehaviorRelay-style state often maps to CurrentValueSubject, but this should not be treated as a blind replacement.

Both can represent “current value” state. But migration still needs to confirm the behavior around initial values, duplicate emissions, direct value access, and delivery timing.

For example, if the new publisher emits an initial value earlier than before, loading logic can run too soon.

If duplicate filtering disappears, UI updates can repeat.

If the value is sent on a different scheduler, the screen can update in a different order.

These are small differences in code and large differences in behavior.

Timing operators are not implementation details

Operators like debounce, delay, receive(on:), and subscribe(on:) are part of the feature.

A debounce before recording an event is not just a performance trick. It may mean “only count this after the user has actually seen it long enough.”

Change the scheduler, cancellation lifetime, or emission source, and the event may fire too early, too late, twice, or never.

That kind of bug usually does not crash.

It shows up later as confusing analytics, inconsistent UI, or a report that starts with “sometimes.”

Reusable UI is where subtle bugs hide

Reusable iOS views make reactive migrations more fragile.

If a reused component keeps old AnyCancellable instances, it may receive events from old state. If it clears subscriptions too aggressively, it may stop receiving events it still needs.

This is especially easy to miss because the UI may look correct during a quick manual test.

The safer rule is simple: every reusable component should have an explicit reset point for its reactive subscriptions.

Do not let old streams survive into new content.

Simplified pseudocode

This is simplified pseudocode, not production code.

import Combine
import Foundation

struct VisibleMarker: Hashable {
    let id: UUID
}

protocol EventSink {
    func recordStableVisibility()
}

final class ScreenVisibilityTracker {
    private let visibleMarkers = CurrentValueSubject<Set<VisibleMarker>, Never>([])
    private var cancellables = Set<AnyCancellable>()

    init(eventSink: EventSink) {
        visibleMarkers
            .filter { !$0.isEmpty }
            .debounce(for: .seconds(3), scheduler: RunLoop.main)
            .sink { _ in
                eventSink.recordStableVisibility()
            }
            .store(in: &cancellables)
    }

    func didAppear(_ marker: VisibleMarker) {
        var markers = visibleMarkers.value
        markers.insert(marker)
        visibleMarkers.send(markers)
    }

    func didDisappear(_ marker: VisibleMarker) {
        var markers = visibleMarkers.value
        markers.remove(marker)
        visibleMarkers.send(markers)
    }
}

The important part is not that this code uses Combine.

The important part is the behavior contract:

  • CurrentValueSubject keeps the current visible state
  • Set prevents duplicate markers
  • debounce records only stable visibility
  • AnyCancellable ownership defines how long tracking exists

During an RxSwift to Combine migration, each of those contracts needs to survive.

A successful RxSwift to Combine migration is not when every import disappears.

It is when the implementation changes, but the iOS behavior stays exactly the same.