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-lineif/elseto 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 case | Combine 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 ofOutputvalues (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:
$query— observe query changes.debounce— wait 300ms of silence before forwarding (don’t hit network every keystroke).removeDuplicates— same query as last time? skip.map— for each query, build a publisher that emits.loadingthen.resultsor.error.switchToLatest— when a new query arrives, cancel the previous pipeline (stops stale “ipad” results from clobbering “iphone”).receive(on: .main)— switch back to main thread for UI.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 x | CurrentValueSubject<X, E> | PassthroughSubject<X, E> | |
|---|---|---|---|
| Stores current value | Yes | Yes | No (transient events) |
| New subscriber receives latest | Yes | Yes | No |
| Has typed failure | No (Never) | Yes | Yes |
| Direct property syntax | Yes | No | No |
| Send manually | Set 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 requests →
async/await(URLSession.data(for:)) - Streams of values →
AsyncSequencefor 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’sobjectWillChangeis aPassthroughSubject. - 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
- “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.
- “
@Publishedworks onletproperties.” No, onlyvar. The publisher fires on the property’sdidSet. - “
.sinkwithout.store(in:)works.” It works until the returnedAnyCancellableis deallocated — usually on the next line. Always store. - “
combineLatestwaits 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. - “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:
- State lives in
@Publishedproperties on view models. Views observe, render. One-way data flow. - Side effects via
.handleEvents— log, trigger analytics, never mutate state outside the pipeline. - Use
.switchToLatestover.flatMapfor user-driven async (search, filter changes) — cancels stale work automatically. receive(on: .main)once, at the end — let upstream operators do work on background queues.- Tests pass synthetic schedulers, not wall-clock waits.
TestSchedulerlets 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. Useassign(to: &$state)(@Publishedform, 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 Future — Future 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.