iosNudge

What is Combine? Explain publishers, subscribers and operators.

MediumCombineConcurrency 2 min read

TL;DR

Combine (iOS 13) handles values over time: text input, network results, notifications, timers.

  • A Publisher emits values, then finishes or fails.
  • Operators transform them (map, filter, debounce, combineLatest).
  • A Subscriber receives them (sink, assign).
  • The subscription lives as long as you keep the AnyCancellable.

Today, new code often uses async/await, AsyncSequence and Observation. But Combine is still common in existing apps, so interviewers still ask about it.

A first example

import Combine

let cancellable = [1, 2, 3].publisher
    .map { $0 * 10 }
    .filter { $0 > 10 }
    .sink { value in
        print(value) // 20, 30
    }

Subjects: publishers you push into

let taps = PassthroughSubject<Void, Never>()      // no current value
let username = CurrentValueSubject<String, Never>("") // has a current value

taps.send()
username.send("ana")
print(username.value) // "ana"

@Published gives a property a publisher ($property):

final class SearchViewModel: ObservableObject {
    @Published var query = ""
    @Published private(set) var results: [String] = []
    private var cancellables = Set<AnyCancellable>()

    init(search: @escaping (String) -> AnyPublisher<[String], Never>) {
        $query
            .debounce(for: .milliseconds(300), scheduler: RunLoop.main)
            .removeDuplicates()
            .filter { $0.count >= 2 }
            .map { search($0) }
            .switchToLatest()                 // cancel the old search
            .receive(on: DispatchQueue.main)
            .assign(to: &$results)            // no cancellable needed
    }
}

This is the classic "search as you type" interview example.

Memory: AnyCancellable

var cancellables = Set<AnyCancellable>()

NotificationCenter.default.publisher(for: UIApplication.didBecomeActiveNotification)
    .sink { [weak self] _ in self?.refresh() }
    .store(in: &cancellables)
  • If you do not store the cancellable, the subscription ends right away.
  • sink closures that capture self strongly create retain cycles. Use [weak self].
  • assign(to: \.prop, on: self) keeps self strongly. That is a cycle. Prefer assign(to: &$prop).

Important operators

OperatorWhat it does
map, filter, compactMapLike on arrays
debounceWait for a pause in values (typing)
throttleAt most one value per interval
removeDuplicatesSkip equal values in a row
combineLatestLatest value from each of several publishers
mergeMix values from publishers of the same type
flatMapStart a new publisher for each value
switchToLatestLike flatMap, but cancels the previous inner publisher
catch, replaceErrorHandle errors
receive(on:)Deliver values on a scheduler, like the main queue

Form validation with combineLatest:

Publishers.CombineLatest($email, $password)
    .map { email, password in email.contains("@") && password.count >= 8 }
    .assign(to: &$isFormValid)

Errors

A publisher can fail once, and then it is finished. If an error reaches the end of a flatMap chain, the whole stream stops. Catch errors inside the flatMap:

.flatMap { query in
    api.search(query)
        .catch { _ in Just([]) }  // the outer stream keeps going
}

Networking

URLSession.shared.dataTaskPublisher(for: url)
    .map(\.data)
    .decode(type: [User].self, decoder: JSONDecoder())
    .receive(on: DispatchQueue.main)
    .sink(receiveCompletion: { print($0) }, receiveValue: { users in print(users) })
    .store(in: &cancellables)

Combine and async/await

for await value in $query.values {   // publisher -> AsyncSequence
    print(value)
}
CombineModern Swift
@Published + ObservableObject@Observable
Future / one-shot publisherasync function
Publisher streamAsyncSequence / AsyncStream
debounce, combineLatest on async sequencesswift-async-algorithms package

Common follow-up questions

  • Hot vs cold publishers? Cold ones start work for each subscriber (dataTaskPublisher). Hot ones emit whether or not anyone listens (subjects, notifications). Use share() to share one upstream.
  • What is back pressure? A subscriber asks for a number of values (Demand). sink asks for unlimited.
  • Why eraseToAnyPublisher()? It hides the long nested generic type behind AnyPublisher<Output, Failure>.
  • Is Combine deprecated? No, but Apple's new APIs focus on Swift concurrency and Observation.