Combine
Combine framework reactive programming patterns for handling asynchronous events in iOS
You are an expert in Apple's Combine framework for building iOS applications with Swift.
## Key Points
- **Use `eraseToAnyPublisher()`** at API boundaries to hide complex generic types and keep interfaces clean.
- **Store cancellables in a `Set<AnyCancellable>`** and use `.store(in:)` for convenient lifetime management.
- **Always `receive(on: DispatchQueue.main)`** before updating UI-bound properties. Combine does not automatically switch threads.
- **Prefer `assign(to: &$published)`** (the `inout` variant) when assigning to `@Published` properties on `self`. It avoids a retain cycle that the older `assign(to:on:)` can create.
- **Use `@Published` for SwiftUI bindings**, but consider Swift Concurrency (`AsyncSequence`) for new one-shot async work. Combine and async/await coexist well.
- **Type-erase errors with `mapError`** early in the pipeline to keep downstream operators simple.
- **Retain cycles with `sink`.** The closure captures `self` strongly by default. Use `[weak self]` in sink closures or prefer `assign(to: &$property)`.
- **Forgetting to store the cancellable.** If the `AnyCancellable` returned by `sink` is not stored, the subscription is immediately cancelled.
- **Using `flatMap` without understanding backpressure.** Each emission spawns a new inner publisher. Use `.flatMap(maxPublishers: .max(1))` to serialize.
- **Debugging opaque pipelines.** Insert `.print("label")` or `.handleEvents(receiveOutput:)` to trace values through a pipeline.
- **Mixing `Never` and typed errors.** `CombineLatest` requires publishers with the same failure type. Use `.setFailureType(to:)` on `Never`-failing publishers to align types.
## Quick Example
```swift
let cancellable = NotificationCenter.default
.publisher(for: UIApplication.didBecomeActiveNotification)
.sink { _ in
print("App became active")
}
```
```swift
let cancellable = viewModel.$username
.assign(to: \.text, on: usernameLabel)
```skilldb get ios-swift-skills/CombineFull skill: 279 linesCombine — iOS/Swift
You are an expert in Apple's Combine framework for building iOS applications with Swift.
Core Philosophy
Combine is a tool for modeling values that change over time as composable pipelines. The key insight is that a publisher-operator-subscriber chain should read like a description of what happens to data, not how it happens. When a Combine pipeline is well designed, you can read it top to bottom and understand the transformation: "take the search text, wait 300ms after the user stops typing, discard duplicates, fetch results, and assign them to the published property." If you cannot read the pipeline out loud and have it make sense, it is too complex and should be broken into named intermediate publishers.
Combine occupies a specific niche in modern Swift development. For one-shot asynchronous operations (fetch a user, save a record), async/await is simpler and should be preferred. Combine's strength is continuous streams: observing user input over time, merging multiple event sources, debouncing, and combining the latest values from several publishers. Using Combine for a single network request that returns once adds complexity without benefit. Use it where its reactive model genuinely simplifies the code.
Lifetime management through cancellables is a core contract, not an implementation detail. Every subscription must be stored or it will be immediately deallocated and cancelled. The store(in: &cancellables) pattern exists because Combine subscriptions are reference-counted. Understanding this ownership model prevents the two most common Combine bugs: subscriptions that silently do nothing because the cancellable was dropped, and retain cycles caused by closures capturing self strongly.
Anti-Patterns
-
Using Combine for one-shot operations where async/await suffices: Wrapping a single network call in
dataTaskPublisherwithsinkandstore(in:)whentry await URLSession.shared.data(from: url)does the same thing in one line. Reserve Combine for streams and multi-value sequences. -
Deeply nested flatMap chains without intermediate publishers: A pipeline with three nested
flatMapoperators and inlinecatchblocks becomes unreadable. Extract each stage into a named publisher or a helper method that returnsAnyPublisher, giving each step a meaningful name. -
Forgetting to store the AnyCancellable: Calling
.sink { }without assigning the result to a stored cancellable means the subscription is immediately cancelled. The closure never fires, and the bug is completely silent. Always store or assign cancellables. -
Using
assign(to:on:)on self without weak capture: The olderassign(to:on:)operator retains its target strongly. If you assign to a property onself, the subscription retains the object, and the object retains the cancellable set, creating a retain cycle. Useassign(to: &$property)instead, which manages its own lifecycle. -
Ignoring error types and using
eraseToAnyPublishertoo early: Type-erasing a publisher before handling its error type hides what can go wrong. Handle errors explicitly withcatch,retry, ormapErrorfirst, then type-erase at the API boundary where the concrete type no longer matters.
Overview
Combine is Apple's first-party reactive programming framework (iOS 13+). It provides a declarative API for processing values over time through publishers, operators, and subscribers. Combine integrates tightly with SwiftUI, Foundation (URLSession, NotificationCenter, Timer), and Core Data. While Swift Concurrency (async/await) has become the preferred tool for many async tasks, Combine remains essential for continuous event streams, complex data pipelines, and SwiftUI bindings via ObservableObject.
Core Concepts
Publishers
A publisher emits a sequence of values over time and eventually completes with a success or a failure. Key built-in publishers:
// Just — emits a single value then completes
let greeting = Just("Hello, Combine!")
// Published property wrapper — emits on every value change
class FormViewModel: ObservableObject {
@Published var email = ""
@Published var password = ""
}
// PassthroughSubject — imperative send, no stored value
let events = PassthroughSubject<AppEvent, Never>()
events.send(.userLoggedIn)
// CurrentValueSubject — like Passthrough but retains the latest value
let connectionStatus = CurrentValueSubject<ConnectionState, Never>(.disconnected)
print(connectionStatus.value) // .disconnected
connectionStatus.send(.connected)
Subscribers
Subscribers receive values from publishers. The most common subscriber is sink:
let cancellable = NotificationCenter.default
.publisher(for: UIApplication.didBecomeActiveNotification)
.sink { _ in
print("App became active")
}
assign is useful for binding a publisher's output directly to a property:
let cancellable = viewModel.$username
.assign(to: \.text, on: usernameLabel)
Cancellables
Every subscription returns an AnyCancellable. When it is deallocated, the subscription is cancelled. Store them to keep subscriptions alive:
class SearchViewModel: ObservableObject {
@Published var query = ""
@Published var results: [SearchResult] = []
private var cancellables = Set<AnyCancellable>()
init() {
$query
.debounce(for: .milliseconds(300), scheduler: RunLoop.main)
.removeDuplicates()
.flatMap { query in
SearchService.search(query: query)
.catch { _ in Just([]) }
}
.receive(on: DispatchQueue.main)
.assign(to: &$results) // assign(to:) on @Published manages its own lifecycle
}
}
Operators
Operators transform, filter, and combine publisher output. They are the heart of Combine pipelines.
Transforming:
// map — transform each value
$price.map { "$\(String(format: "%.2f", $0))" }
// flatMap — transform into a new publisher (one-to-many)
$userId.flatMap { id in
UserService.fetchProfile(id: id)
}
// compactMap — transform and discard nils
$textInput.compactMap { Int($0) }
Filtering:
$searchText
.debounce(for: .seconds(0.3), scheduler: DispatchQueue.main)
.removeDuplicates()
.filter { $0.count >= 2 }
Combining:
// CombineLatest — emit when any input changes, with latest from all
Publishers.CombineLatest($email, $password)
.map { email, password in
!email.isEmpty && password.count >= 8
}
.assign(to: &$isFormValid)
// Merge — interleave multiple publishers of the same type
Publishers.Merge(localResults, remoteResults)
.collect()
.sink { allResults in print(allResults) }
// Zip — pair values one-to-one
Publishers.Zip(profilePublisher, settingsPublisher)
.sink { profile, settings in
configure(profile: profile, settings: settings)
}
Error handling:
urlSession.dataTaskPublisher(for: url)
.map(\.data)
.decode(type: APIResponse.self, decoder: JSONDecoder())
.retry(2)
.catch { error -> Just<APIResponse> in
print("Falling back: \(error)")
return Just(.empty)
}
.receive(on: DispatchQueue.main)
.sink { response in updateUI(response) }
.store(in: &cancellables)
Implementation Patterns
Form Validation Pipeline
class RegistrationViewModel: ObservableObject {
@Published var username = ""
@Published var email = ""
@Published var password = ""
@Published var confirmPassword = ""
@Published var isFormValid = false
@Published var usernameMessage = ""
private var cancellables = Set<AnyCancellable>()
init() {
let validUsername = $username
.debounce(for: .milliseconds(500), scheduler: DispatchQueue.main)
.removeDuplicates()
.flatMap { username -> AnyPublisher<Bool, Never> in
guard username.count >= 3 else {
return Just(false).eraseToAnyPublisher()
}
return UsernameValidator.checkAvailability(username)
.catch { _ in Just(false) }
.eraseToAnyPublisher()
}
let validEmail = $email.map { $0.contains("@") && $0.contains(".") }
let validPassword = Publishers.CombineLatest($password, $confirmPassword)
.map { password, confirm in
password.count >= 8 && password == confirm
}
Publishers.CombineLatest3(validUsername, validEmail, validPassword)
.map { $0 && $1 && $2 }
.assign(to: &$isFormValid)
}
}
Network Request Pipeline
enum APIError: Error {
case invalidResponse, decodingFailed, serverError(Int)
}
struct APIClient {
private let session: URLSession
private let decoder: JSONDecoder
func request<T: Decodable>(_ endpoint: Endpoint) -> AnyPublisher<T, APIError> {
session.dataTaskPublisher(for: endpoint.urlRequest)
.tryMap { data, response in
guard let http = response as? HTTPURLResponse else {
throw APIError.invalidResponse
}
guard (200..<300).contains(http.statusCode) else {
throw APIError.serverError(http.statusCode)
}
return data
}
.decode(type: T.self, decoder: decoder)
.mapError { error in
error as? APIError ?? .decodingFailed
}
.receive(on: DispatchQueue.main)
.eraseToAnyPublisher()
}
}
Timer and Polling
class LiveScoreViewModel: ObservableObject {
@Published var scores: [Score] = []
private var cancellables = Set<AnyCancellable>()
func startPolling() {
Timer.publish(every: 30, on: .main, in: .common)
.autoconnect()
.prepend(Date()) // fire immediately on subscribe
.flatMap { _ in
ScoreService.fetchLatest()
.catch { _ in Empty() }
}
.assign(to: &$scores)
}
}
Best Practices
- Use
eraseToAnyPublisher()at API boundaries to hide complex generic types and keep interfaces clean. - Store cancellables in a
Set<AnyCancellable>and use.store(in:)for convenient lifetime management. - Always
receive(on: DispatchQueue.main)before updating UI-bound properties. Combine does not automatically switch threads. - Prefer
assign(to: &$published)(theinoutvariant) when assigning to@Publishedproperties onself. It avoids a retain cycle that the olderassign(to:on:)can create. - Use
@Publishedfor SwiftUI bindings, but consider Swift Concurrency (AsyncSequence) for new one-shot async work. Combine and async/await coexist well. - Type-erase errors with
mapErrorearly in the pipeline to keep downstream operators simple.
Common Pitfalls
- Retain cycles with
sink. The closure capturesselfstrongly by default. Use[weak self]in sink closures or preferassign(to: &$property). - Forgetting to store the cancellable. If the
AnyCancellablereturned bysinkis not stored, the subscription is immediately cancelled. - Using
flatMapwithout understanding backpressure. Each emission spawns a new inner publisher. Use.flatMap(maxPublishers: .max(1))to serialize. - Debugging opaque pipelines. Insert
.print("label")or.handleEvents(receiveOutput:)to trace values through a pipeline. - Mixing
Neverand typed errors.CombineLatestrequires publishers with the same failure type. Use.setFailureType(to:)onNever-failing publishers to align types.
Install this skill directly: skilldb add ios-swift-skills
Related Skills
App Architecture
MVVM and Clean Architecture patterns for structuring scalable, testable iOS applications
Core Data
Core Data persistence framework for modeling, storing, and querying structured data in iOS apps
Navigation
SwiftUI navigation patterns using NavigationStack, programmatic routing, and deep linking
Networking
URLSession networking patterns for building robust API clients and handling data transfer in iOS
Swift Concurrency
Swift structured concurrency with async/await, actors, and task groups for safe concurrent iOS code
Swiftui
SwiftUI declarative UI framework fundamentals for building modern iOS interfaces