Mastering Combine and Reactive Patterns
May 15, 2025 · 8 min read
iOS, Combine, Reactive, SwiftUI, Interview Prep
Introduction
Combine is Apple's declarative framework for processing values over time. While async/await handles one-shot operations elegantly, Combine excels at continuous streams like user input, real-time updates, and complex data transformations. Understanding when to use each is a common interview topic.
Building reactive features like live search and form validation taught me practical patterns that interviewers look for.
Publishers and Subscribers
The core of Combine: Publishers emit values, Subscribers receive them.
import Combine
// Publisher emits values over time
let publisher = [1, 2, 3, 4, 5].publisher
// Subscriber receives values
let cancellable = publisher
.sink(
receiveCompletion: { completion in
switch completion {
case .finished:
print("Completed")
case .failure(let error):
print("Error: \(error)")
}
},
receiveValue: { value in
print("Received: \(value)")
}
)
// Important: Store the cancellable to keep subscription alive
Common Publishers
// Just - single value then completes
let just = Just(42)
// Future - async operation producing single value
let future = Future<String, Error> { promise in
DispatchQueue.global().async {
promise(.success("Result"))
}
}
// PassthroughSubject - manually send values
let subject = PassthroughSubject<String, Never>()
subject.send("Hello")
subject.send("World")
subject.send(completion: .finished)
// CurrentValueSubject - stores current value
let currentValue = CurrentValueSubject<Int, Never>(0)
print(currentValue.value) // 0
currentValue.send(1)
print(currentValue.value) // 1
PassthroughSubject vs CurrentValueSubject
PassthroughSubject: No initial value, only emits values sent after subscription. Use for events (button taps).
CurrentValueSubject: Has initial value, new subscribers immediately receive current value. Use for state (current user, settings).
Essential Operators
Transformation Operators
// map - transform values
[1, 2, 3].publisher
.map { $0 * 2 }
.sink { print($0) } // 2, 4, 6
// compactMap - transform and filter nil
["1", "two", "3"].publisher
.compactMap { Int($0) }
.sink { print($0) } // 1, 3
// flatMap - transform to new publisher
func fetchUser(id: Int) -> AnyPublisher<User, Error> { ... }
[1, 2, 3].publisher
.flatMap { id in
fetchUser(id: id)
}
.sink(
receiveCompletion: { _ in },
receiveValue: { user in print(user.name) }
)
Filtering Operators
// filter - select values
[1, 2, 3, 4, 5].publisher
.filter { $0.isMultiple(of: 2) }
.sink { print($0) } // 2, 4
// removeDuplicates - filter consecutive duplicates
textPublisher
.removeDuplicates()
.sink { text in /* only fires when text changes */ }
Timing Operators
// debounce - wait for pause in values
searchTextPublisher
.debounce(for: .milliseconds(300), scheduler: RunLoop.main)
.sink { query in performSearch(query) }
// throttle - limit rate of values
locationPublisher
.throttle(for: .seconds(1), scheduler: RunLoop.main, latest: true)
.sink { location in updateMap(location) }
Combining Operators
// combineLatest - combine multiple publishers
Publishers.CombineLatest(usernamePublisher, passwordPublisher)
.map { username, password in
!username.isEmpty && password.count >= 8
}
.sink { isValid in submitButton.isEnabled = isValid }
// merge - combine publishers of same type
let notifications = Publishers.Merge(
NotificationCenter.default.publisher(for: .userLoggedIn),
NotificationCenter.default.publisher(for: .userLoggedOut)
)
Error Handling
// catch - replace error with recovery publisher
apiPublisher
.catch { error -> Just<[Item]> in
print("Error: \(error)")
return Just([]) // Return empty array on error
}
.sink { items in display(items) }
// replaceError - replace error with value
apiPublisher
.replaceError(with: [])
.sink { items in display(items) }
// retry - retry on failure
apiPublisher
.retry(3)
.sink(
receiveCompletion: { _ in },
receiveValue: { data in process(data) }
)
Threading with Schedulers
// Receive on main thread for UI updates
dataPublisher
.receive(on: DispatchQueue.main)
.sink { data in
self.label.text = data // Safe UI update
}
// Subscribe on background, receive on main
heavyPublisher
.subscribe(on: DispatchQueue.global(qos: .background))
.receive(on: DispatchQueue.main)
.sink { result in
self.updateUI(result)
}
@Published and ObservableObject
class SearchViewModel: ObservableObject {
@Published var searchText = ""
@Published var results: [SearchResult] = []
@Published var isLoading = false
private var cancellables = Set<AnyCancellable>()
init() {
setupBindings()
}
private func setupBindings() {
$searchText
.debounce(for: .milliseconds(300), scheduler: RunLoop.main)
.removeDuplicates()
.filter { !$0.isEmpty }
.sink { [weak self] query in
self?.search(query: query)
}
.store(in: &cancellables)
}
private func search(query: String) {
isLoading = true
searchService.search(query: query)
.receive(on: DispatchQueue.main)
.sink(
receiveCompletion: { [weak self] completion in
self?.isLoading = false
},
receiveValue: { [weak self] results in
self?.results = results
}
)
.store(in: &cancellables)
}
}
Combine with SwiftUI
struct SearchView: View {
@StateObject private var viewModel = SearchViewModel()
var body: some View {
VStack {
TextField("Search", text: $viewModel.searchText)
.textFieldStyle(RoundedBorderTextFieldStyle())
if viewModel.isLoading {
ProgressView()
} else {
List(viewModel.results) { result in
Text(result.title)
}
}
}
}
}
// Timer publisher
struct CountdownView: View {
@State private var timeRemaining = 60
let timer = Timer.publish(every: 1, on: .main, in: .common).autoconnect()
var body: some View {
Text("Time: \(timeRemaining)")
.onReceive(timer) { _ in
if timeRemaining > 0 {
timeRemaining -= 1
}
}
}
}
Combine vs Async/Await
When to use each:
| Aspect | Async/Await | Combine |
|---|---|---|
| One-shot operations | ✅ Best choice | Works but verbose |
| Continuous streams | ❌ No built-in support | ✅ Best choice |
| Debounce/throttle | Manual implementation | ✅ Built-in operators |
| Combining multiple sources | async let, TaskGroup | ✅ combineLatest, merge |
| Learning curve | Lower | Higher |
// async/await - one-shot
let user = try await api.fetchUser(id: 1)
// Combine - continuous stream
$searchText
.debounce(for: .milliseconds(300), scheduler: RunLoop.main)
.sink { query in search(query) }
Practical Choice
I use Combine for streams of values (search input, form validation, real-time updates) and async/await for one-shot operations (API calls, database queries). The debounce, combineLatest, and merge operators have no direct async/await equivalent.
Converting Between Async/Await and Combine
// Async function to Publisher
func fetchUserPublisher() -> AnyPublisher<User, Error> {
Future { promise in
Task {
do {
let user = try await fetchUser()
promise(.success(user))
} catch {
promise(.failure(error))
}
}
}
.eraseToAnyPublisher()
}
// Publisher to async
extension Publisher {
func asyncValue() async throws -> Output {
try await withCheckedThrowingContinuation { continuation in
var cancellable: AnyCancellable?
cancellable = first()
.sink(
receiveCompletion: { completion in
if case .failure(let error) = completion {
continuation.resume(throwing: error)
}
cancellable?.cancel()
},
receiveValue: { value in
continuation.resume(returning: value)
}
)
}
}
}
Interview Questions
Q1: What's the difference between map, flatMap, and compactMap?
Answer:
- map: Transforms each value synchronously
- compactMap: Transforms and removes nil values
- flatMap: Transforms to new publisher, flattens results
// map - simple transformation
[1, 2, 3].publisher.map { $0 * 2 } // [2, 4, 6]
// compactMap - filter nil
["1", "two", "3"].publisher.compactMap { Int($0) } // [1, 3]
// flatMap - transform to publisher
[1, 2].publisher.flatMap { id in
fetchUser(id: id) // Returns Publisher<User>
}
// Emits User objects, not publishers
Use flatMap when transformation returns a publisher (like API calls).
Q2: How do you prevent memory leaks with Combine?
Answer: Multiple strategies:
- Weak self in closures:
.sink { [weak self] value in
self?.process(value)
}
- Store cancellables properly:
private var cancellables = Set<AnyCancellable>()
subscription.store(in: &cancellables)
- Assign to published property:
$input.map { transform($0) }.assign(to: &$output)
Q3: When would you use Combine vs async/await?
Answer:
Async/await: Single value operations
- API requests, database queries, file operations
- Simpler syntax, better readability
Combine: Streams of values over time
- User input, location updates, timer events
- Complex transformations (debounce, combineLatest)
// async/await - one-shot
let user = try await api.fetchUser()
// Combine - continuous stream
$searchText.debounce(for: .milliseconds(300), scheduler: RunLoop.main)
Q4: Explain CurrentValueSubject vs PassthroughSubject.
Answer:
| Feature | CurrentValueSubject | PassthroughSubject |
|---|---|---|
| Initial value | Required | None |
| New subscribers | Get current value immediately | Only future values |
.value property | Yes | No |
| Use case | State (current user) | Events (button taps) |
Common Mistakes
1. Forgetting to Store Cancellable
// ❌ Subscription immediately cancelled
func setup() {
publisher.sink { print($0) } // Discarded!
}
// ✅ Store the cancellable
private var cancellables = Set<AnyCancellable>()
func setup() {
publisher.sink { print($0) }.store(in: &cancellables)
}
2. Strong Reference Cycles
// ❌ Strong reference to self
publisher.sink { value in
self.process(value) // Retains self forever
}
// ✅ Weak reference
publisher.sink { [weak self] value in
self?.process(value)
}
3. Not Receiving on Main Thread
// ❌ UI update on background thread
apiPublisher.sink { data in
self.label.text = data // May crash
}
// ✅ Explicit main thread
apiPublisher
.receive(on: DispatchQueue.main)
.sink { data in
self.label.text = data
}
Summary
| Concept | Purpose |
|---|---|
| Publishers | Emit values over time |
| Subscribers | Receive and process values |
| @Published | Create publishers from properties |
| Operators | Transform streams (map, filter, debounce) |
| Schedulers | Control threading |
| AnyCancellable | Manage subscription lifecycle |
Combine remains valuable for reactive patterns that async/await doesn't handle well - particularly continuous streams with timing requirements. Know both tools and when each is appropriate.
Part 6 of the iOS Interview Prep series.