Skip to main content
Technology & EngineeringIos Swift279 lines

Combine

Combine framework reactive programming patterns for handling asynchronous events in iOS

Quick Summary32 lines
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 lines
Paste into your CLAUDE.md or agent config

Combine — 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 dataTaskPublisher with sink and store(in:) when try 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 flatMap operators and inline catch blocks becomes unreadable. Extract each stage into a named publisher or a helper method that returns AnyPublisher, 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 older assign(to:on:) operator retains its target strongly. If you assign to a property on self, the subscription retains the object, and the object retains the cancellable set, creating a retain cycle. Use assign(to: &$property) instead, which manages its own lifecycle.

  • Ignoring error types and using eraseToAnyPublisher too early: Type-erasing a publisher before handling its error type hides what can go wrong. Handle errors explicitly with catch, retry, or mapError first, 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) (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.

Common Pitfalls

  • 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.

Install this skill directly: skilldb add ios-swift-skills

Get CLI access →