RxSwift to Combine: The Complete Transition Guide

Cover Image: Transition from RxSwift to Combine

Combine is the new cool kid in town when it comes to reactive programming with Swift, so many developers in the community want to switch from RxSwift to Combine. Both frameworks are very similar, but the devil’s in the details and if you want to make the transition, it really helps to have a clear mapping from all the RxSwift operators, types, and functions to their Combine equivalents. Well, here it is! If you’re just here for the mapping, you can skip the deep talk and scroll down to the tables. πŸ˜‰ Otherwise, read on to get all the tips and tricks you need for a smooth transition…

Reactive programming comes in handy to keep state in sync between different objects, repositories, or even apps. In iOS development, the most prominent example is to synchronize the state of the user interface with the state of your model. For a long time, RxSwift has been the major player in this field, but with the introduction of Combine, Apple has put a strong competitor in the game that integrates well with SwiftUI. In this article, we’ll have a look at how Combine works, whether it is worth the switch and how you can easily switch from RxSwift to Combine – Let’s go!

ℹ️ If you need more input deciding whether you should switch from RxSwift to Combine (especially with respect to performance and back pressure), we have another article that might help you: Combine vs. RxSwift: Should you switch to Combine?

How does Combine work?

As this article covers the transition from RxSwift to Combine, we will assume in the following that you are already familiar with the RxSwift API – if not, please make sure to have a look at the RxSwift repository on GitHub.

In many ways, Combine is very similar to RxSwift with equivalent concepts, types and methods. Let’s see, where the largest differences are by having a deeper look into how Combine works at its core.

Publisher

In Combine, the equivalent to an Observable is called a Publisher. Where we have a class in RxSwift, Combine uses a protocol:

protocol Publisher {
    associatedtype Output
    associatedtype Failure: Error
    func receive<S: Subscriber>(subscriber: S) 
        where Self.Failure == S.Failure, 
              Self.Output == S.Input
}

Combine does not provide any wrapper types that describe individual characteristics, such as Infallible, Maybe or Single – however, each publisher has its own custom type where these characteristics could be enforced.

RxSwift requires an Observable to override its subscribe function, whereas the Publisher in Combine needs to implement a receive function with a similar interface. In contrast to RxSwift, a Publisher features a Failure type, determining whether/how a publisher can actually fail. For non-failing publishers, we simply use Never as its Failure type.

Combine publishers can be either value or reference types. Most of the publishers provided by Apple are actually value types, i.e. structs.

While operators always return a simple type in RxSwift (Observable), Combine operators return a concrete type that can be quite complex, e.g. Timer.TimerPublisher or CombineLatest<Just<Int>, Just<Int>>. Since the concrete types are quite complicated to write on your own, it makes sense to use AnyPublisher, a type-erased wrapper around the Publisher protocol, to simplify your publishers to a mere AnyPublisher<Int, Never> for example – in this case, we would have a non-failing publisher that emits Int values.

Subscriber

A Subscriber (RxSwift: Observer) receives subscriptions, inputs or completions. In contrast to RxSwift, there is no Event enum that combines value and completion events, but rather individual methods to handle these events separately.

protocol Subscriber: CustomCombineIdentifierConvertible {
    associatedtype Input
    associatedtype Failure: Error

    func receive(subscription: Subscription)
    func receive(_ input: Self.Input) -> Subscribers.Demand
    func receive(completion: Subscribers.Completion<Self.Failure>)
}

As you can see in the code snippet above, a Subscriber also specifies a Failure type, deciding whether it can receive failures (and of which types they can be). While RxSwift specifies an error as a separate event, Combine makes it very clear that a failure terminates the publisher (in RxSwift, an error also terminates the observable, but its interface might suggest otherwise). Subscribers need to be classes, that makes sense since a subscription needs to be able to reference them when forwarding data and not recreate them possibly multiple times during a subscription.

Subscription

A Subscription is what gets created whenever you subscribe (or use a sink when we were to speak Combine), it keeps references to all the necessary resources and can be canceled at any time (RxSwift disposes them instead). This is also the reason, why Subscriptions need to be classes

protocol Subscription: Cancellable, ... {
    func request(_ demand: Subscribers.Demand)
}

protocol Cancellable {
    func cancel()
}

In contrast to RxSwift, Combine also allows subscribers to request more values at any time, resulting in backpressure support. So, rather than the publisher deciding when values are propagated, it is actually the subscriber demanding new values. This allows for the subscriber not to be flooded with values that it cannot process in time, and therefore, possibly a more controlled data flow.

ℹ️ If you need more of an in-depth explanation of back pressure support, there is a nice article from Apple: Processing Published Elements with Subscribers.

Subject

You can think of a Subject , named in RxSwift and Combine the same, as the combination of a subscriber and a publisher. It’s both sender and receiver of data.

protocol Subject: AnyObject, Publisher {
    func send(_ value: Self.Output)
    func send(completion: Subscribers.Completion<Self.Failure>)
    func send(subscription: Subscription)
}

On the one hand, you can send values to the Subject and on the other hand, these values can also be observed, as it also conforms to the Publisher protocol.

While in RxSwift there is a similar interface of observers (i.e. Combine Subscribers) and subjects (i.e. calling onNext, onError, onCompleted), in Combine subscribers receive and subjects send events. However, from every subject, you can create an AnySubscriber to be used when a value conforming to the Subscriber protocol is needed.

As we have seen, both RxSwift and Combine have very similar features but differ slightly in their interface. So, which one is better?


Should I switch from RxSwift?

A big argument for Combine is that it is a first-party framework, so you will not need to fight with dependency management – and your app will be smaller since it does not need to pack a whole other library with it.

Also, RxSwift doesn’t really make it clear whether errors can occur or not. Oftentimes, you just use Observable and hope, that nothing is going to fail. In Combine, it is very clear whether a publisher can fail or not and which errors might occur. However, the interplay of failing and non-failing publishers can often also be quite complicated – and the need to use eraseToAnyPublisher() in pretty much every method does not make this better…

With respect to backpressure support, Combine is just better thought through. Subscribers can demand values when they would like to have them and not simply when the publisher decides to publish them. This makes a whole lot more sense, especially when talking about expensive operations and a lot of data flowing in and out of publishers at the same time.

In general, the API of Combine fits better in the overall Swift environment. Rather than using factory functions on Observable, many extensions on already widely used types (e.g. Timer.publish, (0...3).publisher or Optional<String>.none.publisher) just make working with Combine more intuitive.

But Combine is not as mature/complete as RxSwift, right?

With RxSwift, you do not run into compiler issues as often as with Combine. Non-matching failure types do not matter in RxSwift and one generic constraint is much easier to check than two.

RxSwift supports iOS 9 and higher, while Combine requires iOS 13 and higher. Both frameworks are available on all Apple platforms, but Combine still lacks Linux support. OpenCombine can come to the rescue in these cases, featuring the same API but with an open-source implementation and support for more platforms.

While Combine is more similar to iOS naming conventions, Rx has a very similar API across platforms, making it easy to discuss reactive concepts in cross-platform teams and/or developing applications for multiple platforms with a very similar interface.

The RxSwift community has been around for quite a while, doing a tremendous job with maintaining the RxSwift framework (and more). Many community members have already created resources that help you in niche issues, have fixed bugs that only occur in singular cases, and have extended the framework with their own custom operators or types. Combine is closed source and has only been around for 2 years now, resulting in fewer resources, the need to write some custom operators yourself rather than copying the existing knowledge.

At the moment, it is harder to write a custom Publisher than to write a custom Observable. In Rx, you can simply use Observable.create, emit output, error, and completion events and you are good to go. While there is no direct equivalent in Combine, you can often simply use a Future (essentially a publisher with a completion handler that you should only call once) or write a custom publisher that mimics the behavior of Observable.create (We’ll cover that in the following part of the article).

Let’s summarize!

To the question of whether you should use RxSwift or Combine: it depends. When your priority is getting rid of dependencies, you start a new project that can easily be iOS 13+ and you do not have projects for other platforms, Combine is probably the best option. When you have projects on other platforms, need a deployment target of iOS 12 or less or you want to be able to have that cross-platform API, RxSwift is the more reasonable choice.


How can I switch?

We have assembled the following transition guide, where you can just look up a certain type, factory function, or operator and get help with the Combine equivalent. This should help with the transition from RxSwift to Combine in your projects and help your mind to adapt to the new API.

General Tips

In general, the Combine API is more complicated when it comes to handling errors. One tip for you: It is oftentimes easier to read setFailureType(to: Failure.self) rather than the more general mapError { _ -> Failure in }.

The following resources have helped us in the transition from RxSwift to Combine in some of our projects:

  • CombineExt is a library of many operators that are still missing in Combine that have been added by the Combine community.
  • There is also a cheat sheet for the RxSwift-Combine transition.

Types

Disposable

RxSwiftCombine
DisposableCancellable
Use AnyCancellable to create them like RxSwift’s Disposables.create
DisposeBagSet<AnyCancellable>
any RandomAccessCollection of AnyCancellable
of course, you can also create references one by one

ℹ️ Keep in mind that (in contrast to RxSwift) subscriptions are immediately cancelled when a Cancellable is deinitialized.

Publishers

RxSwiftCombine
Observable<Element>AnyPublisher<Element, Error>
or any other Publisher type
it also makes sense to adapt the Failure type according to the errors being emitted
Single<Element>AnyPublisher<Element, Error>
Future<Element, Error> (sadly, there is not really a way to make sure there is only one value, except for forcing a Future publisher)
ConnectableObservable<Element>ConnectablePublisher
Infallible<Element>AnyPublisher<Element, Never>
Notice: Failure is Never in this case
Maybe<Element>does not exist in Combine

Subjects

BehaviorSubjectCurrentValueSubject
PublishSubjectPassthroughSubject
ReplaySubjectdoes not exist in Combine
πŸ‘‰ replacement in CombineExt
AsyncSubjectdoes not exist in Combine

Relays

Since relays only really limit the interface of a subject, you can easily build them as a wrapper around an existing subject. Relays differ from subjects in their inability to receive/send completion events (both error and finished).

class Relay<SubjectType: Subject>: Publisher,
    CustomCombineIdentifierConvertible where SubjectType.Failure == Never {

    typealias Output = SubjectType.Output
    typealias Failure = SubjectType.Failure

    let subject: SubjectType

    init(subject: SubjectType) {
        self.subject = subject
    }

    func send(_ value: Output) {
        subject
            .send(value)
    }

    func receive<S: Subscriber>(subscriber: S) 
        where Failure == S.Failure, Output == S.Input {
        subject
            .subscribe(on: DispatchQueue.main)
            .receive(subscriber: subscriber)
    }

}

typealias CurrentValueRelay<Output> = Relay<CurrentValueSubject<Output, Never>>
typealias PassthroughRelay<Output> = Relay<PassthroughSubject<Output, Never>>

extension Relay {

    convenience init<O>(_ value: O) 
        where SubjectType == CurrentValueSubject<O, Never> {
        self.init(subject: CurrentValueSubject(value))
    }

    convenience init<O>() 
        where SubjectType == PassthroughSubject<O, Never> {
        self.init(subject: PassthroughSubject())
    }

}

Observer Operators

asObserver()AnySubscriber
on(_:)Subscriber
 ↳ receive(_:)
 ↳ receive(completion:)
Subject
 ↳ send(_:)
 ↳ send(completion:)
onNext(_:)Subscriber
 ↳ receive(_:)
Subject
 ↳ send(_:)
onError(_:)Subscriber
 ↳ receive(completion: .failure(<error>))
Subject
 ↳ send(completion: .failure(<error>))
onCompleted()Subscriber
 ↳ receive(completion: .finished)
Subject
 ↳ send(completion: .finished)
mapObserver(_:)does not exist in Combine, but can easily be implemented (see below)
πŸ‘‰ replacement in CombineExt

To modify the Input or Failure types of a subscriber, you can use one of the following extensions:

extension Subscriber {

    func map<Input>(
        _ map: @escaping (Input) -> Self.Input
    ) -> AnySubscriber<Input, Failure> {
        .init(
            receiveSubscription: receive,
            receiveValue: { self.receive(map($0)) },
            receiveCompletion: receive
        )
    }

    func mapError<Failure>(
        _ map: @escaping (Failure) -> Self.Failure
    ) -> AnySubscriber<Input, Failure> {
        .init(
            receiveSubscription: receive,
            receiveValue: receive,
            receiveCompletion: { completion in
                switch completion {
                case let .failure(error):
                    self.receive(completion: .failure(map(error)))
                case .finished:
                    self.receive(completion: .finished)
                }
            }
        )
    }

}

Scheduling

RxSwift has a lot of different scheduler types: MainScheduler, ConcurrentDispatchQueueScheduler, SerialDispatchQueueScheduler, CurrentThreadScheduler, HistoricalScheduler, OperationQueueScheduler, and possibly even more.

In Combine, this is actually made quite easy – we can simply work with the types we already know from Grand Central Dispatch (GCD) directly: DispatchQueue, OperationQueue and RunLoop. The only scheduler type that was introduced with combine is ImmediateScheduler, which is performing the scheduling task synchronously (similar to CurrentThreadScheduler).

You can find more information about scheduling in Combine in this article.

Factory Functions

amb(...)does not exist in Combine
πŸ‘‰ replacement in CombineExt
catch(sequence:)does not exist in Combine
combineLatest(...)Publishers.CombineLatest
Publishers.CombineLatest3
Publishers.CombineLatest4
Publisher.combineLatest(_:)
Hint: You can easily just chain them like a tree and then map to all the individual tuple values for more
concat(...)Publishers.Concatenate
Publisher.append
Does not support more than 2 values – either use reduce or write a custom implementation
create(_:)Future for single value publishers
πŸ‘‰ replacement in CombineExt for multiple value publishers
deferred(_:)Deferred
empty()Empty
error(_:)Fail
from(_:)Collection.publisher
Optional.publisher
generate( initialState: condition: iterate: )does not exist in Combine, but possibly replaceable with appropriate range/collection manipulation beforehand
just(_:)Just
merge(...)Publishers.Merge
Publishers.Merge3
Publishers.Merge4
Publishers.Merge5
Publishers.Merge6
Publishers.Merge7
Publishers.Merge8
Publishers.MergeMany
never()Empty(completeImmediately: false)
of(...)Collection.publisher
range()Collection.publisher using Range or ClosedRange – use stride
repeatElement(_:)does not exist in Combine
timer(_:period:scheduler:)Timer.publish
+ delay
+ autoconnect
using(_:observableFactory:)does not exist in Combine
zip(...)Publishers.Zip
Publishers.Zip3
Publishers.Zip4
πŸ‘‰ replacement in CombineExt

Publisher / Observable Operators

amb(_:)does not exist in Combine
πŸ‘‰ replacement in CombineExt
asCompletable()does not exist in Combine
asObservable()eraseToAnyPublisher()
buffer( timeSpan: count: scheduler: )collect( .byTimeOrCount(_:_:_:), options: )
catch(_:)
catchError(_:)
catch(_:)
tryCatch(_:)
catchAndReturn(_:)
catchErrorJustReturn(_:)
replaceError(with:)
compactMap(_:)compactMap(_:)
tryCompactMap(_:)
concat(...)append
Publishers.Concatenate
concatMap(_:)does not exist in Combine
could be built using reduce(_:_:)
and append(_:)
debounce(_:scheduler:)debounce( for: scheduler: options: )
debug( _: trimOutput: file: line: function: )print(_:to:)
delay(_:scheduler:)delay( for: tolerance: scheduler: options: )
delaySubscription( _: scheduler: )does not exist in Combine
Idea: Use Deferred combined with delay(for:scheduler:options:)
dematerialize()does not exist in Combine
πŸ‘‰ replacement in CombineExt
distinctUntilChanged(...)removeDuplicates()
when Output is Equatable
removeDuplicates(by:)
tryRemoveDuplicates(by:)
do( onNext: afterNext: onError: afterError: onCompleted: afterCompleted: onSubscribe: onSubscribed: onDispose: )handleEvents( receiveSubscription: receiveOutput: receiveCompletion: receiveCancel: receiveRequest: )

no onDispose: cancel is not called on completed publishers, so you would need to write the same code twice (or hide that with a custom extension, e.g. handleTermination(_:))
element(at:)output(at:)
enumerated()does not exist in Combine
Idea: use scan(_:_:) in an intelligent way
filter(_:)filter(_:)
tryFilter(_:)
first()first()
flatMapFirst(_:)does not exist in Combine
flatMap(_:)flatMap(_:)
tryFlatMap(_:)
flatMapLatest(_:)map(_:) followed by switchToLatest()
πŸ‘‰ replacement in CombineExt
groupBy(keySelector:)does not exist in Combine
ifEmpty(default:)replaceEmpty(with:)
ifEmpty(switchTo:)does not exist in Combine
ignoreElements()ignoreOutput()
map(_:)map(_:)
tryMap(_:)
materialize()does not exist in Combine
πŸ‘‰ replacement in CombineExt
multicast(_:)multicast(subject:)
multicast(makeSubject:)multicast(_:)
observe(on:)observe(on:)
Combine uses different scheduler types!
publish()multicast { PassthroughSubject() }
makeConnectable()
reduce(into:_:)does not exist in Combine
could be implemented with reduce
reduce(_:_:)reduce(_:_:)
tryReduce(_:_:)
reduce( _: accumulator: mapResult: )reduce(_:_:) followed
by map(_:) or tryMap(_:)
refCount()autoconnect()
replay(_:)does not exist in Combine
could be implemented with multicast(_:) and ReplaySubject
replayAll()does not exist in Combine
multicast(_:) with unbounded ReplaySubject
retry()retry(.max)
slightly different meaning, but should normally not make a difference
retry(_:)retry(_:)
retry(when:)does not exist in Combine
sample(_:defaultValue:)does not exist in Combine
scan(into:_:)does not exist in Combine
could be implemented with regular scan(_:_:)
scan(_:_:)scan(_:_:)
tryScan(_:_:)
share(replay:scope:)share()

or equivalently: multicast(_:) with PassthroughSubject and autoconnect() or with ReplaySubject…

see RxSwift implementation of share(replay:scope:) for more information

πŸ‘‰ extension in CombineExt
single()does not exist in Combine
first() but with an error, when there is more or less than 1 output values
single(_:)does not exist in Combine
filter(_:) followed by a single() replacement
skip(_:)dropFirst(_:)
skip(while:)drop(while:)
skip(until:)drop(untilOutputFrom:)
startWith(...)prepend(...)
subscribe(_:)closest match:
sink( receiveValue: receiveCompletion: )
subscribe(on:)subscribe(on:options:)
Combine uses different scheduler types!
subscribe( onNext: onError: onCompleted: onDisposed: )closest match:
sink( receiveValue: receiveCompletion: )
subscribe( with: onNext: onError: onCompleted: onDisposed: )closest match:
sink( receiveValue: receiveCompletion: ) with [weak object]
switchLatest()switchToLatest()
take(_:)prefix(_:)
take(for:scheduler:)does not exist in Combine

could be solved with:
prefix(untilOutputFrom: Timer.publish( every: <time>, on: <scheduler> ) .autoconnect() .prefix(1) )

πŸ‘‰ replacement in CombineExt
take(until:)prefix(untilOutputFrom:)
take(until:behavior:)prefix(while:) with negated condition
does not contain behavior
take(while:behavior:)prefix(while:)
does not contain behavior
takeLast(_:)does not exist in Combine
closest match: reduce([]) { $0 + [$1] } .flatMap { $0.suffix(<count>).publisher }
throttle( _: latest: scheduler: )throttle( for: scheduler: latest: )
timeout( _: other: scheduler: )closest match:
timeout( _: scheduler: options: customError: )
then catch(_:) with map to the other publisher
timeout(_:scheduler:)timeout( _: scheduler: options: customError: )
toArray()collect()
window( timeSpan: count: scheduler: )collect( .byTime(<scheduler>, <time>) )

collect( .byTimeOrCount( <scheduler>, <time>, <count> ) )
withLatestFrom(_:)does not exist in Combine
πŸ‘‰ replacement in CombineExt
withLatestFrom( _: resultSelector: )does not exist in Combine
πŸ‘‰ replacement in CombineExt
withUnretained(_:)closest match:
compactMap { [weak object] value in object.map { ($0, value) } }
withUnretained( _: resultSelector: )closest match:
compactMap { [weak object] value in object.map { resultSelector($0, value) } }

Conclusion

If you are still with us and read the entire article up to this point, you must be a real RxSwift-Combine-transition guru now! For us, it still feels weird to write all these setFailureType(to:) and eraseToAnyPublisher() calls. But other than that, we really love Combine. What do you think about it? Have you run into any issues with Combine so far? Let us know on Twitter!

🐣

Get notified when our next article is born!

(no spam, just one app-development-related article per month)