Swift Combine Framework — Reactive Programming with Publishers and Subscribers
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
Not storing AnyCancellable: Unstored subscriptions cancel immediately. Always call
.store(in: &cancellables).Strong reference cycles in sink: Capturing
selfstrongly in asinkclosure while the publisher is held byselfcreates a cycle. Use[weak self]in sinks.Forgetting to receive on main queue: URLSession and other background publishers emit on background threads. Use
.receive(on: DispatchQueue.main)before UI updates.Overusing PassthroughSubject: Subjects are powerful but often unnecessary. Use
@Publishedin SwiftUI or built-in publishers (URLSession, NotificationCenter) when possible.Not handling completion and failure: Always handle both
.finishedand.failureinsink(receiveCompletion:)to avoid silent errors.
Practice Questions
- What is the difference between PassthroughSubject and CurrentValueSubject?
- Why must you store AnyCancellable instances?
- What does the
debounceoperator do? - How does Combine integrate with SwiftUI's ObservableObject?
- 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
WeatherServiceclass usingURLSession.dataTaskPublisherto fetch weather data - A
WeatherViewModelObservableObject with@Publishedproperties for temperature, condition, and loading state - A text field publisher with debounce for city search
- Combine's
combineLatestto update the UI when either temperature or condition changes - Error handling with
catchandmapErroroperators - A timer publisher for auto-refresh every 60 seconds
FAQ
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