Combine Framework: Reactive Programming in Swift
Master Combine for handling asynchronous data streams in Swift: Publishers, Subscribers, Operators and advanced patterns for iOS applications.

Reactive programming transforms how asynchronous events and data are handled in iOS applications. Combine, Apple's native framework, provides a declarative and type-safe approach to orchestrating complex data flows. This guide covers fundamental concepts through production-ready patterns.
Combine is built into iOS 13+, delivers better performance through Apple's optimizations, and integrates seamlessly with SwiftUI. No external dependencies to manage.
Combine Core Concepts
Combine is built on three key concepts: Publishers that emit values, Subscribers that receive them, and Operators that transform data between the two. This architecture enables building reactive, composable data pipelines.
Publisher: The Data Source
A Publisher is a type that can emit a sequence of values over time. Each Publisher declares two associated types: the emitted value type (Output) and the possible error type (Failure). Here's how to create different types of Publishers:
import Combine
// Just: emits a single value then completes
// Useful for converting a simple value to a Publisher
let singleValue = Just("Hello Combine")
// CurrentValueSubject: stores and emits the current value
// Perfect for representing state that changes over time
let counter = CurrentValueSubject<Int, Never>(0)
// PassthroughSubject: emits values without storing them
// Ideal for one-time events (taps, notifications)
let buttonTaps = PassthroughSubject<Void, Never>()
// Future: emits a single value asynchronously
// Wraps an async operation that returns a result
let asyncOperation = Future<String, Error> { promise in
// Simulate a network call
DispatchQueue.global().asyncAfter(deadline: .now() + 1) {
promise(.success("Data loaded"))
}
}The Never type for errors means the Publisher can never fail. This is a compile-time guarantee that simplifies error handling code.
Subscriber: Receiving Values
A Subscriber subscribes to a Publisher to receive its values. The sink method is the most common way to create a Subscriber. It takes two closures: one for errors/completion and one for each received value:
import Combine
// Variable to store subscriptions
// Without this reference, the subscription would be immediately cancelled
var cancellables = Set<AnyCancellable>()
let publisher = ["Swift", "Combine", "iOS"].publisher
// sink() creates a Subscriber that receives values
publisher
.sink(
// Called when the Publisher completes or fails
receiveCompletion: { completion in
switch completion {
case .finished:
print("✅ Completed successfully")
case .failure(let error):
print("❌ Error: \(error)")
}
},
// Called for each emitted value
receiveValue: { value in
print("Received: \(value)")
}
)
// store() keeps a reference to the subscription
.store(in: &cancellables)
// Output:
// Received: Swift
// Received: Combine
// Received: iOS
// ✅ Completed successfullyAlways store the AnyCancellable returned by sink(). Without a reference, the subscription is automatically cancelled and no values are received.
Transforming Data with Operators
Operators are the heart of Combine. They enable transforming, filtering, and combining data streams declaratively. Each Operator returns a new Publisher, allowing them to be chained together.
Essential Transformation Operators
Transformation Operators modify each emitted value. map transforms values, flatMap flattens nested Publishers, and compactMap filters out nil values:
import Combine
var cancellables = Set<AnyCancellable>()
// map: transforms each value
// Equivalent to map on arrays
[1, 2, 3, 4, 5].publisher
.map { $0 * 2 } // Multiply each number by 2
.sink { print("Doubled: \($0)") }
.store(in: &cancellables)
// Output: 2, 4, 6, 8, 10
// compactMap: transforms AND filters out nil
// Useful for optional conversions
["1", "two", "3", "four", "5"].publisher
.compactMap { Int($0) } // Convert to Int, ignore failures
.sink { print("Valid number: \($0)") }
.store(in: &cancellables)
// Output: 1, 3, 5
// flatMap: flattens nested Publishers
// Essential for chaining async operations
struct User { let id: Int; let name: String }
func fetchUser(id: Int) -> AnyPublisher<User, Never> {
// Simulate an API call
Just(User(id: id, name: "User \(id)"))
.delay(for: .milliseconds(100), scheduler: RunLoop.main)
.eraseToAnyPublisher()
}
[1, 2, 3].publisher
.flatMap { id in fetchUser(id: id) } // Each ID becomes an API call
.sink { user in print("User: \(user.name)") }
.store(in: &cancellables)Filtering Operators
Filtering Operators control which values pass through the pipeline. They are essential for avoiding unnecessary processing and optimizing performance:
import Combine
var cancellables = Set<AnyCancellable>()
let numbers = [1, 2, 2, 3, 3, 3, 4, 5, 5].publisher
// filter: keeps only values that satisfy the condition
numbers
.filter { $0 > 2 } // Keep only numbers > 2
.sink { print("Filtered: \($0)") }
.store(in: &cancellables)
// Output: 3, 3, 3, 4, 5, 5
// removeDuplicates: removes consecutive identical values
numbers
.removeDuplicates() // Eliminate consecutive duplicates
.sink { print("Without duplicates: \($0)") }
.store(in: &cancellables)
// Output: 1, 2, 3, 4, 5
// debounce: waits for a pause before emitting
// Perfect for real-time search
let searchText = PassthroughSubject<String, Never>()
searchText
.debounce(for: .milliseconds(300), scheduler: RunLoop.main)
.removeDuplicates() // Ignore if text hasn't changed
.sink { query in
print("Search: \(query)")
// Launch API call here
}
.store(in: &cancellables)
// Simulate rapid typing
searchText.send("S")
searchText.send("Sw")
searchText.send("Swi")
searchText.send("Swift") // Only "Swift" is emitted after 300msReady to ace your iOS interviews?
Practice with our interactive simulators, flashcards, and technical tests.
Combining Multiple Publishers
Real-world applications often need to combine multiple data sources. Combine provides several Operators to orchestrate these multiple streams.
CombineLatest and Zip
combineLatest emits whenever any Publisher emits, combining with the latest values from others. zip waits for all Publishers to emit before combining:
import Combine
var cancellables = Set<AnyCancellable>()
// Simulate a form with validation
let email = CurrentValueSubject<String, Never>("")
let password = CurrentValueSubject<String, Never>("")
// combineLatest: combines the latest values from each Publisher
// Emits on every change from either source
Publishers.CombineLatest(email, password)
.map { email, password in
// Validate that email contains @ and password > 6 chars
let isEmailValid = email.contains("@")
let isPasswordValid = password.count >= 6
return isEmailValid && isPasswordValid
}
.sink { isFormValid in
print("Form valid: \(isFormValid)")
}
.store(in: &cancellables)
email.send("user@example.com") // false (password empty)
password.send("123456") // true (both are valid)
// zip: waits for one value from each Publisher before emitting
// Useful for synchronizing parallel operations
let firstAPI = PassthroughSubject<String, Never>()
let secondAPI = PassthroughSubject<Int, Never>()
Publishers.Zip(firstAPI, secondAPI)
.sink { stringValue, intValue in
print("Received pair: \(stringValue), \(intValue)")
}
.store(in: &cancellables)
firstAPI.send("Hello") // No emission, waiting for secondAPI
secondAPI.send(42) // Emits: ("Hello", 42)
firstAPI.send("World") // No emission, waiting for secondAPI
secondAPI.send(100) // Emits: ("World", 100)Merge for Unifying Streams
merge combines multiple Publishers of the same type into a single stream. Values arrive in emission order regardless of which Publisher sent them:
import Combine
var cancellables = Set<AnyCancellable>()
// Multiple user notification sources
let pushNotifications = PassthroughSubject<String, Never>()
let localNotifications = PassthroughSubject<String, Never>()
let inAppMessages = PassthroughSubject<String, Never>()
// Merge unifies all streams into one
Publishers.Merge3(pushNotifications, localNotifications, inAppMessages)
.sink { message in
// Handle all notifications the same way
print("📬 Notification: \(message)")
}
.store(in: &cancellables)
pushNotifications.send("New message") // 📬 Notification: New message
localNotifications.send("Reminder: meeting") // 📬 Notification: Reminder: meeting
inAppMessages.send("Welcome!") // 📬 Notification: Welcome!Error Handling in Combine
Error handling is built into Combine's core. The Failure type of Publishers enables the compiler to verify that all errors are handled.
Recovery Strategies
Combine offers several Operators for handling errors: catch to replace with another Publisher, retry to try again, and replaceError for a default value:
import Combine
var cancellables = Set<AnyCancellable>()
enum APIError: Error {
case networkError
case invalidResponse
case serverError(Int)
}
// Simulate an API call that can fail
func fetchData() -> AnyPublisher<String, APIError> {
Fail(error: APIError.networkError)
.eraseToAnyPublisher()
}
// retry: retries N times before propagating the error
fetchData()
.retry(3) // Try up to 3 times
.catch { error -> Just<String> in
// catch: replaces the error with a fallback Publisher
print("Error after 3 attempts: \(error)")
return Just("Cached data") // Fallback value
}
.sink(
receiveCompletion: { _ in },
receiveValue: { print("Result: \($0)") }
)
.store(in: &cancellables)
// replaceError: replaces any error with a fixed value
// Simpler than catch when only a default value is needed
fetchData()
.replaceError(with: "Error - default value")
.sink { print("With fallback: \($0)") }
.store(in: &cancellables)Use setFailureType(to:) to change a Never Publisher to one that can fail, and replaceError(with:) or catch to do the reverse.
MVVM Pattern with Combine
Combine integrates naturally with the MVVM (Model-View-ViewModel) pattern. The ViewModel exposes Publishers that the View observes, creating a reactive binding between data and the interface.
Complete Reactive ViewModel
Here's an example ViewModel for a user list with search, loading, and error handling:
import Combine
import Foundation
// Data model
struct User: Codable, Identifiable {
let id: Int
let name: String
let email: String
}
// ViewModel with reactive state
final class UserListViewModel: ObservableObject {
// MARK: - Published Properties (observed by SwiftUI)
@Published var users: [User] = [] // User list
@Published var searchQuery: String = "" // Search text
@Published var isLoading: Bool = false // Loading state
@Published var errorMessage: String? // Optional error message
// MARK: - Private Properties
private var cancellables = Set<AnyCancellable>()
private let userService: UserServiceProtocol
// MARK: - Computed Properties
// Filters users based on search query
var filteredUsers: [User] {
guard !searchQuery.isEmpty else { return users }
return users.filter {
$0.name.localizedCaseInsensitiveContains(searchQuery)
}
}
// MARK: - Initialization
init(userService: UserServiceProtocol = UserService()) {
self.userService = userService
setupBindings()
}
// MARK: - Private Methods
private func setupBindings() {
// Observe searchQuery changes
// debounce prevents too frequent calls
$searchQuery
.debounce(for: .milliseconds(300), scheduler: RunLoop.main)
.removeDuplicates()
.sink { [weak self] query in
// Server-side search logic if needed
print("Search updated: \(query)")
}
.store(in: &cancellables)
}
// MARK: - Public Methods
func loadUsers() {
isLoading = true
errorMessage = nil
userService.fetchUsers()
.receive(on: DispatchQueue.main) // Ensure UI updates on main thread
.sink(
receiveCompletion: { [weak self] completion in
self?.isLoading = false
if case .failure(let error) = completion {
self?.errorMessage = error.localizedDescription
}
},
receiveValue: { [weak self] users in
self?.users = users
}
)
.store(in: &cancellables)
}
}Service with Combine and URLSession
URLSession natively integrates Combine via dataTaskPublisher. Here's how to create a reusable network service:
import Combine
import Foundation
protocol UserServiceProtocol {
func fetchUsers() -> AnyPublisher<[User], Error>
}
final class UserService: UserServiceProtocol {
private let baseURL = URL(string: "https://api.example.com")!
private let session: URLSession
private let decoder: JSONDecoder
init(session: URLSession = .shared) {
self.session = session
self.decoder = JSONDecoder()
decoder.keyDecodingStrategy = .convertFromSnakeCase
}
func fetchUsers() -> AnyPublisher<[User], Error> {
let url = baseURL.appendingPathComponent("users")
return session.dataTaskPublisher(for: url)
// Check HTTP status code
.tryMap { data, response in
guard let httpResponse = response as? HTTPURLResponse else {
throw URLError(.badServerResponse)
}
guard 200..<300 ~= httpResponse.statusCode else {
throw URLError(.badServerResponse)
}
return data
}
// Decode JSON to Swift model
.decode(type: [User].self, decoder: decoder)
// Erase concrete type to return AnyPublisher
.eraseToAnyPublisher()
}
}Ready to ace your iOS interviews?
Practice with our interactive simulators, flashcards, and technical tests.
SwiftUI Integration
Combine and SwiftUI form a powerful duo. The @Published properties of an ObservableObject automatically trigger view updates.
SwiftUI View with Combine ViewModel
Here's how to connect the ViewModel to a SwiftUI view:
import SwiftUI
struct UserListView: View {
// StateObject: creates and owns the ViewModel
@StateObject private var viewModel = UserListViewModel()
var body: some View {
NavigationStack {
Group {
if viewModel.isLoading {
// Centered loading indicator
ProgressView("Loading...")
} else if let error = viewModel.errorMessage {
// Error view with retry button
VStack(spacing: 16) {
Text("Error: \(error)")
.foregroundStyle(.red)
Button("Retry") {
viewModel.loadUsers()
}
}
} else {
// User list
List(viewModel.filteredUsers) { user in
UserRowView(user: user)
}
}
}
.navigationTitle("Users")
.searchable(text: $viewModel.searchQuery) // Direct binding
.onAppear {
viewModel.loadUsers() // Load on first appearance
}
}
}
}
struct UserRowView: View {
let user: User
var body: some View {
VStack(alignment: .leading, spacing: 4) {
Text(user.name)
.font(.headline)
Text(user.email)
.font(.subheadline)
.foregroundStyle(.secondary)
}
.padding(.vertical, 4)
}
}Advanced Patterns
Cancellation and Automatic Cleanup
Subscription lifecycle management is crucial for avoiding memory leaks. The cancellables pattern with AnyCancellable ensures automatic cleanup:
import Combine
final class DataManager {
// Set of cancellables: automatically cancelled on destruction
private var cancellables = Set<AnyCancellable>()
// Individual cancellable for fine-grained control
private var currentRequest: AnyCancellable?
func startPolling() {
// Timer that emits every 5 seconds
Timer.publish(every: 5, on: .main, in: .common)
.autoconnect() // Starts automatically
.sink { [weak self] _ in
self?.fetchLatestData()
}
.store(in: &cancellables)
}
func fetchLatestData() {
// Cancel the previous request if it exists
currentRequest?.cancel()
currentRequest = URLSession.shared
.dataTaskPublisher(for: URL(string: "https://api.example.com/data")!)
.map(\.data)
.decode(type: [String].self, decoder: JSONDecoder())
.replaceError(with: [])
.receive(on: DispatchQueue.main)
.sink { data in
print("Data received: \(data)")
}
}
deinit {
// All cancellables are automatically cancelled
print("DataManager destroyed, subscriptions cancelled")
}
}Schedulers for Threading
Schedulers control which thread operations execute on. Use subscribe(on:) for background work and receive(on:) for UI updates:
import Combine
import Foundation
var cancellables = Set<AnyCancellable>()
func loadAndProcessData() -> AnyPublisher<ProcessedData, Error> {
URLSession.shared.dataTaskPublisher(for: apiURL)
// Perform parsing on a background thread
.subscribe(on: DispatchQueue.global(qos: .userInitiated))
.map(\.data)
.decode(type: RawData.self, decoder: JSONDecoder())
// Heavy processing on background thread
.map { rawData in
// This expensive operation runs in the background
processData(rawData)
}
// Return to main thread for UI
.receive(on: DispatchQueue.main)
.eraseToAnyPublisher()
}Conclusion
Combine provides a powerful and declarative approach for handling asynchronous data streams in iOS applications. Key takeaways:
✅ Publishers emit values over time ✅ Subscribers receive and process those values ✅ Operators transform and combine streams ✅ AnyCancellable manages subscription lifecycle ✅ @Published with SwiftUI creates automatic reactive bindings ✅ Schedulers control threading for optimal performance
Mastering Combine enables building robust, maintainable, and reactive iOS applications. Its native integration with SwiftUI makes it an essential tool for modern iOS development.
Start practicing!
Test your knowledge with our interview simulators and technical tests.
Tags
Share
Related articles

Combine vs async/await in Swift: Progressive Migration Patterns
Complete guide to migrating from Combine to async/await in Swift: progressive strategies, bridging patterns, and paradigm coexistence in iOS codebases.

iOS Accessibility Interview Questions in 2026: VoiceOver and Dynamic Type
Prepare for iOS interviews with key accessibility questions: VoiceOver, Dynamic Type, semantic traits, and accessibility audits.

Swift Macros: Practical Metaprogramming Examples
Complete guide to Swift Macros: creating freestanding and attached macros, AST manipulation with swift-syntax, and practical examples to eliminate boilerplate code.