At some point while studying iOS development, you inevitably encounter RxSwift or Combine. They appear in nearly every job posting, but opening the code can be intimidating when map, flatMap, and sink appear in unfamiliar chains. “The app works fine with closures and delegates, so do I really need to learn this?” is a natural question.
Before discussing syntax, let’s first organize why these tools became necessary. Once you understand why, the syntax follows.
iOS apps are really “event-processing machines”
When you break down what an app does, most of it is responding to events.
- The user taps a button → change the screen
- A network response arrives → refresh the list
- The keyboard appears → move the input field upward
- A text field’s value changes → request search results again
The problem is that UIKit delivers these events in different ways.
| Event source | Delivery method |
|---|---|
| Button tap | target-action |
| Table view scroll | delegate |
| Network response | completion handler (closure) |
| Keyboard appearance | NotificationCenter |
| Object property change | KVO(Key-Value Observing) |
It is perfectly normal for all five to be used on one iOS screen. The task is the same—responding to an event—but the code is scattered across five different forms, so understanding one screen’s behavior requires searching throughout the view controller.
That is the first problem RxSwift and Combine aim to solve. They unify every event as a “stream of values flowing over time”. Button taps, network responses, and keyboard notifications can all be handled through the same interface.
Two walls you hit when relying on callbacks
Wall 1: Composing asynchronous work
A common requirement is: “Request user information and recent posts for the profile screen at the same time, then render the screen when both arrive.” With completion handlers, it looks like this.
var user: User?
var posts: [Post]?
func loadProfile() {
let group = DispatchGroup()
group.enter()
api.fetchUser { result in
user = try? result.get()
group.leave()
}
group.enter()
api.fetchPosts { result in
posts = try? result.get()
group.leave()
}
group.notify(queue: .main) {
guard let user, let posts else { /* Where should error handling go?? */ return }
render(user, posts)
}
}
You need DispatchGroup, temporary storage variables, and scattered error handling. As requests grow to three or four, and dependencies such as “request B with A’s result when A finishes” appear, nesting becomes unmanageable. This is callback hell.
In Combine, the same requirement is expressed like this.
api.fetchUser()
.zip(api.fetchPosts())
.receive(on: DispatchQueue.main)
.sink(receiveCompletion: { completion in
if case .failure(let error) = completion { showError(error) }
}, receiveValue: { user, posts in
render(user, posts)
})
.store(in: &cancellables)
“Zip the two, receive them on the main thread, then notify on success and on failure.” The code almost reads exactly like the requirement. Error handling is centralized too.
Wall 2: Controlling continuous events
Consider a search field. If you call the API on every keystroke, typing “Swift” sends five requests. So conditions like these are usually added.
- Request only after input has stopped for 0.3 seconds (debounce)
- Do not request if the query matches the previous one (deduplication)
- When a new request starts, cancel the previous one if it has not finished
Implementing this directly with Timer and flag variables creates bug-prone code as timer invalidation and request cancellation timing become intertwined. In Combine, you simply chain proven operators.
searchTextSubject
.debounce(for: .seconds(0.3), scheduler: DispatchQueue.main)
.removeDuplicates()
.map { api.search(query: $0) }
.switchToLatest() // Automatically cancel the previous request when a new one arrives
.sink { results in render(results) }
.store(in: &cancellables)
That is the key: assemble proven components instead of implementing event-control logic involving time yourself. This is where reactive programming’s value is clearest.
Standard components for MVVM binding
As covered in the previous article, MVVM (Model-View-ViewModel) is complete only when it has binding that automatically updates the View when the ViewModel changes. UIKit has no built-in binding, however. RxSwift (RxCocoa) filled that gap, and since iOS 13, Combine has taken over the role.
viewModel.$isLoading
.sink { [weak self] in self?.spinner.isAnimating = $0 }
.store(in: &cancellables)
This is the practical reason so many job postings require RxSwift or Combine. In MVVM-based codebases, the binding layer is effectively built with one of these two frameworks.
RxSwift and Combine—what’s different?
Conceptually, both are reactive programming tools. It is fair to say that Observable became Publisher and subscribe became sink, with mostly just the names changing. Once you learn one, you can move to the other with a terminology mapping. Still, the selection criteria are clear.
- RxSwift: A third-party library. It has no iOS version constraint, offers rich UIKit binding through RxCocoa, and benefits from extensive documentation and community support. The tradeoffs are an external dependency and longer build times.
- Combine: An Apple first-party framework. It works on iOS 13 and later without adding dependencies and integrates naturally with SwiftUI’s
@PublishedandObservableObject. Its UIKit binding support is thinner than RxCocoa’s, however.
For a new project, Combine is the safe default. RxSwift is often learned when joining a team whose existing codebase already uses it.
Is it still necessary now that async/await exists?
Since Swift 5.5 introduced async/await, people often ask, “Do I still need to learn Combine?” They are half right. For one-off asynchronous work that ends with “one request, one response”, async/await is much easier to read. The profile example above takes async let just two lines.
But streams of values that keep flowing in, like the search field, are different. When ongoing events—text input, location updates, WebSocket messages, or ViewModel state changes—need time-based controls such as debounce or combineLatest, reactive tools remain the right fit. Apple is expanding this area with AsyncSequence, but the operator ecosystem is still richer around Combine.
The summary is:
- One-off asynchronous work (such as a network request) → async/await
- Persistent event streams + time-based control and UI binding → Combine (or RxSwift)
They are less competitors than tools with different areas of responsibility.
Conclusion
In one sentence, why use RxSwift and Combine? To unify disparate asynchronous events through one stream interface, then declaratively handle composition, time-based control, and binding on top of it.
- Unify event handling scattered across delegates, closures, and notifications
- Express composition of multiple asynchronous tasks without nested callbacks
- Handle time-based controls such as debounce, deduplication, and request cancellation with proven operators
- The de facto standard component for MVVM binding
The syntax looks intimidating at first, but once you adopt the perspective of “viewing events as streams of values,” operators are simply arrays’ map and filter extended along the time axis. In the next article, we will examine how Combine’s Publisher and Subscriber actually work together internally.

