4.10 — UIKit + Combine

Opening scenario

You inherited a UISearchController-driven product search VC. The current code:

  • Fires a network request on every keystroke (300 RPM at peak)
  • Sometimes shows stale results (request for “ipad” finishes after “iphone”)
  • Maintains 4 boolean flags (isLoading, hasError, lastQueryEmpty, didCancel) and a 60-line if/else to derive what to render
  • No tests because the logic is tangled in delegate methods

You rewrite it with Combine — Apple’s reactive framework. The state becomes a pipeline: text input → debounce → de-duplicate → switch-to-latest network request → map to view state → bind to UI. 80 lines, deterministic, testable.

Combine is no longer Apple’s future — that’s AsyncSequence / Swift Concurrency. But Combine remains the strongest tool for declarative reactive pipelines in UIKit codebases, and you’ll encounter it in every senior interview and most established apps.

Use caseCombine fits
Search debounce + switchToLatest✅ Native
Form validation across N fields✅ Native
View-model state pipelines✅ Native
One-off async fetch❌ Use async/await
Iterating over a stream of values⚠️ Use AsyncSequence for new code

Concept → Why → How → Code

Vocabulary

  • Publisher — emits a stream of Output values (or finishes with an error)
  • Subscriber — receives values; the contract is “give me one at a time, demand more when I’m ready”
  • Operator — a publisher transformed from another publisher (.map, .filter, .debounce)
  • Cancellable — token you keep alive to keep the subscription running; deinit cancels
import Combine

let publisher = ["a", "b", "c"].publisher
let cancellable = publisher
    .map { $0.uppercased() }
    .sink { print($0) }   // A, B, C

@Published — the workhorse for state

A property that publishes its changes:

final class FeedViewModel {
    @Published var query: String = ""
    @Published private(set) var state: ViewState = .idle

    enum ViewState {
        case idle, loading, results([Article]), error(String)
    }
}

$query is the publisher; query is the value. You can .sink on $query to observe changes:

let vm = FeedViewModel()
let c = vm.$query.sink { print("query is now \($0)") }
vm.query = "hello"   // prints "query is now hello"

A real search pipeline

import Combine
import Foundation

final class SearchViewModel {
    @Published var query: String = ""
    @Published private(set) var state: State = .idle

    enum State { case idle, loading, results([Product]), error(String) }

    private let api: APIClient
    private var cancellables: Set<AnyCancellable> = []

    init(api: APIClient) {
        self.api = api
        bind()
    }

    private func bind() {
        $query
            .debounce(for: .milliseconds(300), scheduler: DispatchQueue.main)
            .removeDuplicates()
            .map { [api] query -> AnyPublisher<State, Never> in
                guard !query.isEmpty else {
                    return Just(.idle).eraseToAnyPublisher()
                }
                return api.searchPublisher(query: query)
                    .map { State.results($0) }
                    .catch { error in Just(State.error(error.localizedDescription)) }
                    .prepend(.loading)
                    .eraseToAnyPublisher()
            }
            .switchToLatest()
            .receive(on: DispatchQueue.main)
            .assign(to: &$state)
    }
}

What this does:

  1. $query — observe query changes
  2. .debounce — wait 300ms of silence before forwarding (don’t hit network every keystroke)
  3. .removeDuplicates — same query as last time? skip
  4. .map — for each query, build a publisher that emits .loading then .results or .error
  5. .switchToLatest — when a new query arrives, cancel the previous pipeline (stops stale “ipad” results from clobbering “iphone”)
  6. .receive(on: .main) — switch back to main thread for UI
  7. .assign(to: &$state) — publish into our @Published var state

The view controller observes state and renders:

final class SearchVC: UIViewController {
    let vm: SearchViewModel
    var cancellables: Set<AnyCancellable> = []

    func bind() {
        vm.$state
            .sink { [weak self] state in self?.render(state) }
            .store(in: &cancellables)

        searchBar.publisher(for: \.text)
            .compactMap { $0 }
            .assign(to: \.query, on: vm)
            .store(in: &cancellables)
    }
}

(Note: UISearchBar doesn’t natively expose a Combine publisher for text; add a small extension via delegate or KVO.)

Building publishers from UIKit

UIKit isn’t Combine-native, but you can bridge:

extension UIControl {
    struct EventPublisher<Control: UIControl>: Publisher {
        typealias Output = Control
        typealias Failure = Never
        let control: Control
        let event: UIControl.Event

        func receive<S: Subscriber>(subscriber: S) where S.Input == Output, S.Failure == Failure {
            let subscription = EventSubscription(subscriber: subscriber, control: control, event: event)
            subscriber.receive(subscription: subscription)
        }
    }

    final class EventSubscription<S: Subscriber, Control: UIControl>: Subscription
        where S.Input == Control {
        private var subscriber: S?
        private weak var control: Control?

        init(subscriber: S, control: Control, event: UIControl.Event) {
            self.subscriber = subscriber
            self.control = control
            control.addTarget(self, action: #selector(handle), for: event)
        }

        func request(_ demand: Subscribers.Demand) {}
        func cancel() { subscriber = nil; control = nil }

        @objc private func handle() {
            guard let control else { return }
            _ = subscriber?.receive(control)
        }
    }

    func publisher(for event: UIControl.Event) -> EventPublisher<Self> {
        EventPublisher(control: self, event: event)
    }
}

// Usage
button.publisher(for: .touchUpInside)
    .sink { _ in print("tapped") }
    .store(in: &cancellables)

For UITextField:

extension UITextField {
    var textPublisher: AnyPublisher<String, Never> {
        publisher(for: .editingChanged)
            .map { $0.text ?? "" }
            .eraseToAnyPublisher()
    }
}

Then form validation:

Publishers.CombineLatest(emailField.textPublisher, passwordField.textPublisher)
    .map { email, password in isValidEmail(email) && password.count >= 8 }
    .assign(to: \.isEnabled, on: submitButton)
    .store(in: &cancellables)

Common operators you’ll actually use

  • .map, .compactMap, .tryMap — transform
  • .filter — drop values
  • .removeDuplicates — dedupe consecutive equal values
  • .debounce(for:scheduler:) — emit only after silence
  • .throttle(for:scheduler:latest:) — at most one per interval
  • .combineLatest, .zip — combine multiple publishers
  • .merge — interleave outputs
  • .flatMap, .switchToLatest — chain into new publishers
  • .handleEvents(receiveOutput:receiveCompletion:) — side effects (logging)
  • .assign(to:on:) — bind to a property
  • .sink(receiveValue:) — terminal subscriber

Memory: cancellables & retain cycles

AnyCancellable cancels its subscription on deinit. The pattern:

var cancellables: Set<AnyCancellable> = []

somePublisher
    .sink { value in /* ... */ }
    .store(in: &cancellables)

When the VC deinits, the set deinits, cancellables cancel, subscriptions tear down. Don’t keep the cancellable in a local variable — it’ll deinit immediately and cancel before any value arrives.

Retain cycles in .sink closures: capture [weak self]:

publisher
    .sink { [weak self] value in
        self?.update(value)
    }
    .store(in: &cancellables)

If you don’t, the closure retains self, self retains the Cancellable set, the set retains the subscription, the subscription retains the closure → cycle.

Error handling

Publishers have a Failure type. Operators that can throw produce typed errors:

URLSession.shared.dataTaskPublisher(for: url)   // Failure == URLError
    .map(\.data)
    .decode(type: Feed.self, decoder: JSONDecoder())   // Failure == Error (broader)
    .receive(on: DispatchQueue.main)
    .sink(
        receiveCompletion: { completion in
            if case .failure(let error) = completion { print(error) }
        },
        receiveValue: { feed in /* ... */ }
    )

Once a publisher errors, it’s done — no more values. To recover and continue:

.catch { error in Just([]).setFailureType(to: Error.self) }

Or .replaceError(with:) to swap any error for a fallback value.

@Published vs CurrentValueSubject vs PassthroughSubject

@Published var xCurrentValueSubject<X, E>PassthroughSubject<X, E>
Stores current valueYesYesNo (transient events)
New subscriber receives latestYesYesNo
Has typed failureNo (Never)YesYes
Direct property syntaxYesNoNo
Send manuallySet the property.send(_:).send(_:)

Rule of thumb: @Published for view-model state; CurrentValueSubject if you need typed failure; PassthroughSubject for events that don’t have a “current” value (taps, notifications).

Combine vs async/await in 2026

Apple introduced AsyncSequence and async/await partly to replace Combine for many use cases:

  • One-off requestsasync/await (URLSession.data(for:))
  • Streams of valuesAsyncSequence for new code, but Combine still widely used in UIKit codebases
  • Complex reactive pipelines (debounce, combineLatest, switchToLatest) → Combine still wins; AsyncSequence operators are limited
  • UIKit property bindings (@Published → text field, button enabled) → Combine

In practice, codebases written 2019-2022 are Combine-heavy. 2024+ projects mix: async/await for sequential async, Combine for reactive UI. SwiftUI uses Combine under the hood (ObservableObject, @Published).

Bridging:

// Combine → async/await
let value = try await publisher.values.first(where: { _ in true })

// async/await → Combine
let publisher = Future<Value, Error> { promise in
    Task {
        do {
            let v = try await fetchValue()
            promise(.success(v))
        } catch {
            promise(.failure(error))
        }
    }
}

Testing Combine pipelines

Combine pipelines are pure functions of their inputs (given a sequence of inputs at times, produce a sequence of outputs at times). That makes them deterministic and testable:

func test_emptyQueryProducesIdle() {
    let api = MockAPIClient()
    let vm = SearchViewModel(api: api)

    let expectation = XCTestExpectation()
    vm.$state
        .dropFirst()   // skip initial value
        .first()
        .sink { state in
            if case .idle = state { expectation.fulfill() }
        }
        .store(in: &cancellables)

    vm.query = ""
    wait(for: [expectation], timeout: 1)
}

For .debounce, inject a TestScheduler (third-party libraries like CombineSchedulers from Point-Free, or roll your own) so tests don’t actually wait 300ms.

In the wild

  • Apple’s own SwiftUI is built on Combine. ObservableObject’s objectWillChange is a PassthroughSubject.
  • Robinhood iOS has many Combine pipelines for ticker streams: WebSocket → decode → throttle to 1Hz per ticker → de-dupe → bind to view.
  • Airbnb’s MvRx pattern (their internal architecture) uses Combine for view model state derivation.
  • Lyft uses Combine extensively for form validation and search debouncing.
  • Mozilla’s iOS Focus browser uses Combine for the URL bar suggestion pipeline (debounce, history search, sync).

Common misconceptions

  1. “Combine is dead because Apple promotes async/await.” No — Combine is still actively used, supported, and the best tool for reactive (vs sequential) async. Apple ships SwiftUI on Combine internals.
  2. @Published works on let properties.” No, only var. The publisher fires on the property’s didSet.
  3. .sink without .store(in:) works.” It works until the returned AnyCancellable is deallocated — usually on the next line. Always store.
  4. combineLatest waits for all publishers to emit.” Yes — and emits no value until each has emitted at least once. If one publisher never emits, the combined never emits.
  5. “Threading is handled automatically.” No. Publishers emit on whatever queue they were created on. Use .receive(on: DispatchQueue.main) before UI updates.

Seasoned engineer’s take

Combine is declarative async state management. Once you wire it correctly, the bug class of “state is in 4 places, hard to keep in sync, race conditions when network is flaky” mostly disappears.

Rules I follow:

  1. State lives in @Published properties on view models. Views observe, render. One-way data flow.
  2. Side effects via .handleEvents — log, trigger analytics, never mutate state outside the pipeline.
  3. Use .switchToLatest over .flatMap for user-driven async (search, filter changes) — cancels stale work automatically.
  4. receive(on: .main) once, at the end — let upstream operators do work on background queues.
  5. Tests pass synthetic schedulers, not wall-clock waits. TestScheduler lets you advance time and assert what the pipeline emits.

TIP: When debugging “why isn’t my pipeline emitting?”, add .print("debug") at multiple points. It logs every event (subscribed, value, completion, cancelled). Disposable but invaluable.

WARNING: assign(to:on:) (one argument: target, key path, object) strongly retains the target object. Use assign(to: &$state) (@Published form, no retention) or [weak self] + .sink { self?.x = $0 }.

Interview corner

Junior-level: “What’s the difference between flatMap and switchToLatest?”

flatMap keeps every inner publisher alive — values from old ones can still arrive. switchToLatest (applied to a publisher of publishers) cancels the previous inner publisher when a new outer value arrives. Use switchToLatest for “only care about the latest request” patterns like search.

Mid-level: “How would you implement form validation across 3 fields, where the submit button enables only when all are valid?”

Publishers.CombineLatest3(
    emailField.textPublisher.map(isValidEmail),
    passwordField.textPublisher.map { $0.count >= 8 },
    confirmField.textPublisher
)
.map { emailValid, passwordValid, confirm in
    emailValid && passwordValid && confirm == passwordField.text
}
.assign(to: \.isEnabled, on: submitButton)
.store(in: &cancellables)

Each field’s editing-changed event flows through. CombineLatest3 emits a tuple whenever any of the three emits. The transform decides whether all conditions hold.

Senior-level: “Design a real-time ticker streaming UI: 100 stocks, server pushes updates over WebSocket up to 50/sec, UI throttles to 1 update per stock per second, batches by 100ms, sorts the list, applies a diffable snapshot.”

WebSocket → PassthroughSubject<TickerUpdate, Never>. Group by ticker ID with a dictionary [String: CurrentValueSubject<TickerUpdate, Never>]; each per-ticker subject is .throttle(for: 1, scheduler: DispatchQueue.global(), latest: true). Merge all throttled streams, then .collect(.byTime(scheduler, 0.1)) to batch by 100ms, then .map { batch in apply(batch) -> sortedSnapshot }, then .receive(on: .main), then .sink { snapshot in dataSource.apply(snapshot) }. Tests with TestScheduler: feed synthetic events, advance virtual time, assert snapshots. Profile with Instruments to confirm we don’t allocate excessively per update.

Red flag in candidates: Treating every async task as a FutureFuture is for one-shot work, runs immediately even without subscribers (eager), retains its closure forever. For repeatable work, use AnyPublisher from a PassthroughSubject or wrap a function in Deferred { Future { ... } }.

Lab preview

Combine threads through Phase 6 (SwiftUI + Combine) extensively. In Phase 4 labs, you can extend Lab 4.1 with a Combine pipeline for the search bar as a stretch goal.


Phase 4 chapters complete. Continue with Lab 4.1 — News reader.