RxSwift to Combine: The Complete Transition Guide
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
RxSwift
|
Combine
|
---|---|
Disposable |
Cancellable Use AnyCancellable to create them like RxSwift’s Disposables.create |
DisposeBag |
Set<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
RxSwift
|
Combine
|
---|---|
Observable<Element> |
AnyPublisher<Element, Error> or any other Publisher typeit 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
PublishSubject |
PassthroughSubject |
ReplaySubject |
does not exist in Combine 👉 replacement in CombineExt |
AsyncSubject |
does 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() |
|
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(start:count:scheduler:) |
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: 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. |
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(_:_:) followedby map(_:) or tryMap(_:) |
refCount() |
autoconnect() |
replay(_:) |
does not exist in Combine could be implemented with multicast(_:) and ReplaySubject |
replayAll() |
does not exist in Combinemulticast(_:) 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:) |
or equivalently: see RxSwift implementation of |
single() |
does not exist in Combinefirst() but with an error, when there is more or less than 1 output values |
single(_:) |
does not exist in Combinefilter(_:) followed by a single() replacement |
skip(_:) |
dropFirst(_:) |
skip(while:) |
drop(while:) |
skip(until:) |
drop(untilOutputFrom:) |
startWith(...) |
prepend(...) |
subscribe(_:) |
closest match: Documentation sink( receiveValue: receiveCompletion: ) |
subscribe(on:) |
subscribe(on:options:) Combine uses different scheduler types! |
subscribe( onNext: onError: onCompleted: onDisposed: ) |
closest match: Documentation sink( receiveValue: receiveCompletion: ) |
subscribe( with: onNext: onError: onCompleted: onDisposed: ) |
closest match:sink(receiveValue:receoveCompletion:) with [weak object] |
switchLatest() |
switchToLatest() |
take(_:) |
prefix(_:) |
take(for:scheduler:) |
does not exist in Combine could be solved with: prefix( untilOutputFrom: Timer.publish(every:on:) .autoconnect() .prefix(1) ) |
take(until:) |
prefix(untilOutputFrom:) |
take(until:behavior:) |
prefix(while:) with negated conditiondoes not contain behavior |
take(while:behavior:) |
prefix(while:) does not contain behavior |
takeLast(_:) |
does not exist in Combine closest match: reduce(_:_:)
reduce([]) { $0 + [$1] } .flatMap { $0.suffix(<count>).publisher } |
throttle( _: latest: scheduler: ) |
throttle( for: scheduler: latest: ) |
timeout( _: other: scheduler: ) |
closest match: timeout followed by catch |
timeout( _: scheduler: ) |
timeout( _: scheduler: options: customError: ) |
toArray() |
collect() |
window( timeSpan: count: scheduler: ) |
|
withLatestFrom(_:) |
does not exist in Combine 👉 replacement in CombineExt |
withLatestFrom( _: resultSelector: ) |
does not exist in Combine 👉 replacement in CombineExt |
withUnretained(_:) |
closest match: compactMap(_:) compactMap { [weak object] value in object .map { ($0, value) } } |
withUnretained( _: resultSelector: ) |
closest match: compactMap(_:) 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.