Bobby Encoded
PostsAbout
PostsAbout

© 2026 Bobby Jose

← Back to Blog

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:

AspectAsync/AwaitCombine
One-shot operations✅ Best choiceWorks but verbose
Continuous streams❌ No built-in support✅ Best choice
Debounce/throttleManual implementation✅ Built-in operators
Combining multiple sourcesasync let, TaskGroup✅ combineLatest, merge
Learning curveLowerHigher
// 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:

  1. Weak self in closures:
.sink { [weak self] value in
    self?.process(value)
}
  1. Store cancellables properly:
private var cancellables = Set<AnyCancellable>()
subscription.store(in: &cancellables)
  1. 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:

FeatureCurrentValueSubjectPassthroughSubject
Initial valueRequiredNone
New subscribersGet current value immediatelyOnly future values
.value propertyYesNo
Use caseState (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

ConceptPurpose
PublishersEmit values over time
SubscribersReceive and process values
@PublishedCreate publishers from properties
OperatorsTransform streams (map, filter, debounce)
SchedulersControl threading
AnyCancellableManage 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.

← Previous

Building Custom SwiftUI Views and Animations

Next →

iOS Data Persistence: Core Data, SwiftData, and Beyond