Skip to content

Swift Combine Framework — Reactive Programming with Publishers and Subscribers

DodaTech Updated 2026-06-28 9 min read

In this tutorial, you will learn about Swift Combine Framework. We cover key concepts, practical examples, and best practices to help you master this topic.

The Swift Combine framework provides a declarative Swift API for processing values over time using publishers that emit events and subscribers that react to them, enabling Reactive Programming without external dependencies.

What You'll Learn

  • Publishers, Subscribers, and Operators
  • Built-in publishers (NotificationCenter, URLSession, Timer)
  • Subjects for manual event injection
  • Key operators: map, filter, flatMap, combineLatest, merge
  • Cancellables and memory management
  • Integrating Combine with UIKit and SwiftUI

Why It Matters

Combine replaces callback-based patterns (delegates, completion handlers, KVO) with a unified reactive pipeline. UI updates, network responses, text field changes, and timer events can all be processed through the same composable API. SwiftUI was built alongside Combine and uses ObservableObject and @Published — both Combine types — as its data flow foundation.

Real-World Use

A real-time search feature uses Combine to observe a text field, debounce input by 300ms, filter out short queries, call a search API, and update the results table — all in a few lines of declarative code without manual timer management or callback nesting.

Learning Path

flowchart LR
  A[Notifications
Lesson 23] --> B[Combine Framework
You are here] B --> C[SwiftUI Basics
Lesson 25] B --> D[SwiftUI Data Flow
Lesson 26] style B fill:#f90,color:#fff

Publishers and Subscribers

A publisher emits values over time. A subscriber receives them. Operators transform the stream between them.

import Combine
import Foundation

// A simple publisher that emits integers
let publisher = [1, 2, 3, 4, 5].publisher

// A subscriber that receives values
let subscriber = Subscribers.Sink<Int, Never>(
    receiveCompletion: { completion in
        switch completion {
        case .finished:
            print("Stream completed")
        case .failure(let error):
            print("Error: \(error)")
        }
    },
    receiveValue: { value in
        print("Received: \(value)")
    }
)

publisher.subscribe(subscriber)

Output:

Received: 1
Received: 2
Received: 3
Received: 4
Received: 5
Stream completed

Using sink and assign

sink is the most common subscriber. assign binds values to a key path on an object.

import Combine

let numbers = [10, 20, 30, 40, 50].publisher

var cancellables = Set<AnyCancellable>()

numbers
    .filter { $0 > 25 }
    .map { "Value: \($0)" }
    .sink { print($0) }
    .store(in: &cancellables)

class TemperatureLabel {
    var text: String = "" {
        didSet { print("Label updated: \(text)") }
    }
}

let temperatures = [22.5, 23.0, 21.8, 24.1].publisher
let label = TemperatureLabel()

temperatures
    .map { "\($0)°C" }
    .assign(to: \.text, on: label)
    .store(in: &cancellables)

Output:

Value: 30
Value: 40
Value: 50
Label updated: 22.5°C
Label updated: 23.0°C
Label updated: 21.8°C
Label updated: 24.1°C

Always store AnyCancellable instances. When they are deallocated, the subscription is cancelled.

Built-in Publishers

NotificationCenter Publisher

import Combine
import UIKit

extension Notification.Name {
    static let customEvent = Notification.Name("customEvent")
}

class CombineNotifications {
    var cancellables = Set<AnyCancellable>()

    func observeKeyboard() {
        NotificationCenter.default.publisher(for: UIResponder.keyboardWillShowNotification)
            .sink { notification in
                if let frame = notification.userInfo?[UIResponder.keyboardFrameEndUserInfoKey]
                    as? CGRect {
                    print("Keyboard will show: height \(frame.height)")
                }
            }
            .store(in: &cancellables)
    }

    func postCustomEvent() {
        NotificationCenter.default.post(
            name: .customEvent,
            object: nil,
            userInfo: ["message": "Hello from Combine"]
        )
    }
}

let observer = CombineNotifications()
observer.observeKeyboard()

// Simulate notification
NotificationCenter.default.post(
    name: UIResponder.keyboardWillShowNotification,
    object: nil,
    userInfo: [UIResponder.keyboardFrameEndUserInfoKey: CGRect(x: 0, y: 0, width: 390, height: 346)]
)

URLSession Publisher

import Combine

struct Post: Decodable {
    let id: Int
    let title: String
    let body: String
}

class APIService {
    var cancellables = Set<AnyCancellable>()

    func fetchPosts() {
        let url = URL(string: "https://jsonplaceholder.typicode.com/posts")!

        URLSession.shared.dataTaskPublisher(for: url)
            .map { $0.data }
            .decode(type: [Post].self, decoder: JSONDecoder())
            .receive(on: DispatchQueue.main)
            .sink(receiveCompletion: { completion in
                switch completion {
                case .finished:
                    print("Fetch completed")
                case .failure(let error):
                    print("Fetch failed: \(error)")
                }
            }, receiveValue: { posts in
                print("Received \(posts.count) posts")
                for post in posts.prefix(3) {
                    print("  #\(post.id): \(post.title)")
                }
            })
            .store(in: &cancellables)
    }
}

let api = APIService()
api.fetchPosts()

Timer Publisher

import Combine

class TimerDemo {
    var cancellables = Set<AnyCancellable>()
    var count = 0

    func startTimer() {
        Timer.publish(every: 1.0, on: .main, in: .common)
            .autoconnect()
            .sink { [weak self] date in
                guard let self = self else { return }
                self.count += 1
                print("Tick \(self.count): \(date.formatted(date: .omitted, time: .standard))")
                if self.count >= 5 {
                    self.cancellables.removeAll()
                    print("Timer stopped")
                }
            }
            .store(in: &cancellables)
    }
}

let timerDemo = TimerDemo()
timerDemo.startTimer()

// Let the timer run
Thread.sleep(forTimeInterval: 6.0)

Subjects

Subjects are both publishers (you can subscribe to them) and subscribers (you can send values into them).

PassthroughSubject

Emits values to subscribers without storing state.

import Combine

class EventBus {
    static let shared = EventBus()
    let eventSubject = PassthroughSubject<String, Never>()

    private var cancellables = Set<AnyCancellable>()

    private init() {
        eventSubject
            .sink { event in
                print("[EventBus] Event: \(event)")
            }
            .store(in: &cancellables)
    }

    func sendEvent(_ event: String) {
        eventSubject.send(event)
    }
}

let bus = EventBus.shared
var subCancellables = Set<AnyCancellable>()

bus.eventSubject
    .filter { $0.hasPrefix("user_") }
    .sink { print("  Filtered: \($0)") }
    .store(in: &subCancellables)

bus.sendEvent("user_login")
bus.sendEvent("data_refresh")
bus.sendEvent("user_logout")

Output:

[EventBus] Event: user_login
  Filtered: user_login
[EventBus] Event: data_refresh
[EventBus] Event: user_logout
  Filtered: user_logout

CurrentValueSubject

Emits values and stores the most recent value, delivering it immediately to new subscribers.

import Combine

class UserSession {
    let isLoggedIn = CurrentValueSubject<Bool, Never>(false)
    let username = CurrentValueSubject<String, Never>("Guest")

    private var cancellables = Set<AnyCancellable>()

    init() {
        isLoggedIn
            .sink { [weak self] loggedIn in
                guard let self = self else { return }
                print("Login status: \(loggedIn) — User: \(self.username.value)")
            }
            .store(in: &cancellables)
    }

    func login(name: String) {
        username.send(name)
        isLoggedIn.send(true)
    }

    func logout() {
        username.send("Guest")
        isLoggedIn.send(false)
    }
}

let session = UserSession()
print("Initial state: logged in = \(session.isLoggedIn.value)")

session.login(name: "Alice")

// A new subscriber immediately gets the current value
session.isLoggedIn
    .sink { print("  Late subscriber sees: loggedIn = \($0)") }
    .store(in: &Set<AnyCancellable>())

session.logout()

Output:

Login status: false — User: Guest
Initial state: logged in = false
Login status: true — User: Alice
  Late subscriber sees: loggedIn = true
Login status: false — User: Guest

Key Operators

map and filter

let numbers = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10].publisher

numbers
    .filter { $0 % 2 == 0 }
    .map { $0 * $0 }
    .sink { print("Even square: \($0)") }
    .store(in: &Set<AnyCancellable>())

Output:

Even square: 4
Even square: 16
Even square: 36
Even square: 64
Even square: 100

combineLatest

Combines the latest values from two publishers.

import Combine

let usernamePublisher = PassthroughSubject<String, Never>()
let agePublisher = PassthroughSubject<Int, Never>()

usernamePublisher
    .combineLatest(agePublisher)
    .map { "\($0) is \($1) years old" }
    .sink { print($0) }
    .store(in: &Set<AnyCancellable>())

usernamePublisher.send("Alice")
usernamePublisher.send("Bob")
agePublisher.send(25)    // Latest: Bob, 25
usernamePublisher.send("Charlie")  // Latest: Charlie, 25
agePublisher.send(30)    // Latest: Charlie, 30

Output:

Bob is 25 years old
Charlie is 25 years old
Charlie is 30 years old

merge

Combines multiple publishers of the same type into a single stream.

let publisher1 = [1, 2, 3].publisher
let publisher2 = [4, 5, 6].publisher
let publisher3 = [7, 8, 9].publisher

Publishers.Merge3(publisher1, publisher2, publisher3)
    .sink { print($0, terminator: " ") }
    .store(in: &Set<AnyCancellable>())
print()

debounce and throttle

Control how often values are emitted, essential for search fields.

import Combine

let searchSubject = PassthroughSubject<String, Never>()

searchSubject
    .debounce(for: .milliseconds(300), scheduler: DispatchQueue.main)
    .removeDuplicates()
    .filter { $0.count >= 3 }
    .sink { print("Searching for: \($0)") }
    .store(in: &Set<AnyCancellable>())

searchSubject.send("S")
searchSubject.send("Sw")
searchSubject.send("Swi")
searchSubject.send("Swift")
searchSubject.send("Swift")
searchSubject.send("SwiftC")
searchSubject.send("SwiftCo")
searchSubject.send("SwiftCom")
searchSubject.send("SwiftComp")
searchSubject.send("SwiftCompi")
searchSubject.send("SwiftCompil")
searchSubject.send("SwiftCompile")

Only the final value after 300ms of inactivity triggers the search. removeDuplicates skips repeated identical values.

Combine with SwiftUI

Combine is the reactive engine behind SwiftUI's data flow.

import SwiftUI
import Combine

class SearchViewModel: ObservableObject {
    @Published var searchText = ""
    @Published var results: [String] = []
    @Published var isSearching = false

    private var cancellables = Set<AnyCancellable>()
    private let allItems = ["Apple", "Banana", "Cherry", "Date", "Elderberry",
                            "Fig", "Grape", "Kiwi", "Lemon", "Mango"]

    init() {
        $searchText
            .debounce(for: .milliseconds(300), scheduler: DispatchQueue.main)
            .removeDuplicates()
            .map { [weak self] query -> [String] in
                guard let self = self else { return [] }
                if query.isEmpty { return [] }
                return self.allItems.filter { $0.localizedCaseInsensitiveContains(query) }
            }
            .sink { [weak self] filteredResults in
                self?.results = filteredResults
                self?.isSearching = false
            }
            .store(in: &cancellables)
    }

    func search(_ query: String) {
        isSearching = true
        searchText = query
    }
}

// In SwiftUI:
// struct SearchView: View {
//     @StateObject private var viewModel = SearchViewModel()
//
//     var body: some View {
//         VStack {
//             TextField("Search...", text: $viewModel.searchText)
//             if viewModel.isSearching {
//                 ProgressView()
//             }
//             List(viewModel.results, id: \.self) { item in
//                 Text(item)
//             }
//         }
//     }
// }

Memory Management with Combine

class CombineMemoryManager {
    private var cancellables = Set<AnyCancellable>()

    func setupSubscriptions() {
        NotificationCenter.default.publisher(for: .customEvent)
            .sink { _ in print("Event received") }
            .store(in: &cancellables)

        Timer.publish(every: 5, on: .main, in: .common)
            .autoconnect()
            .sink { _ in print("Timer fired") }
            .store(in: &cancellables)
    }

    func cancelAll() {
        cancellables.removeAll()
    }

    deinit {
        print("CombineMemoryManager deallocated — subscriptions cancelled")
    }
}

When the manager is deallocated, all subscriptions cancel automatically via cancellables.removeAll() in deinit.

Common Mistakes

  1. Not storing AnyCancellable: Unstored subscriptions cancel immediately. Always call .store(in: &cancellables).

  2. Strong reference cycles in sink: Capturing self strongly in a sink closure while the publisher is held by self creates a cycle. Use [weak self] in sinks.

  3. Forgetting to receive on main queue: URLSession and other background publishers emit on background threads. Use .receive(on: DispatchQueue.main) before UI updates.

  4. Overusing PassthroughSubject: Subjects are powerful but often unnecessary. Use @Published in SwiftUI or built-in publishers (URLSession, NotificationCenter) when possible.

  5. Not handling completion and failure: Always handle both .finished and .failure in sink(receiveCompletion:) to avoid silent errors.

Practice Questions

  1. What is the difference between PassthroughSubject and CurrentValueSubject?
  2. Why must you store AnyCancellable instances?
  3. What does the debounce operator do?
  4. How does Combine integrate with SwiftUI's ObservableObject?
  5. Challenge: Build a real-time form validator using Combine that validates an email field (must contain @ and .), password field (8+ characters with uppercase and number), and confirms passwords match. The submit button should only enable when all validations pass.

Mini Project

Create a WeatherApp with:

  • A WeatherService class using URLSession.dataTaskPublisher to fetch weather data
  • A WeatherViewModel ObservableObject with @Published properties for temperature, condition, and loading state
  • A text field publisher with debounce for city search
  • Combine's combineLatest to update the UI when either temperature or condition changes
  • Error handling with catch and mapError operators
  • A timer publisher for auto-refresh every 60 seconds

FAQ

Is Combine only for iOS 13+?

Combine is available from iOS 13, macOS 10.15, watchOS 6, and tvOS 13. For earlier OS versions, use third-party reactive libraries like RxSwift.

Should I use Combine or async/await?

Use async/await for one-shot async tasks (network calls, file I/O). Use Combine for continuous event streams (text changes, notifications, timers). They work well together.

What is the @Published property wrapper?

@Published creates a publisher for a property. Changes to the property automatically emit through the publisher, triggering SwiftUI view updates.

How do I handle errors in Combine?

Use catch to recover from errors, mapError to transform error types, retry to attempt again, or replaceError to provide a default value.

Can I create custom publishers?

Yes. Conform to the Publisher protocol or use Future for one-shot values and Deferred for lazy evaluation.

What's Next

Apply Combine concepts with SwiftUI Basics to build declarative UIs, or explore SwiftUI Data Flow for advanced state management.

Built by the developers of DodaTech

Doda Browser, DodaZIP & Durga Antivirus Pro