Swift Combine

Apple's reactive programming framework for handling asynchronous events

🔄 What is Swift Combine?

Combine is Apple's reactive programming framework that provides a declarative Swift API for processing values over time. It handles asynchronous events by combining event-processing operators, making complex data flows easier to manage and understand.


import Combine

// Simple publisher that emits values
let publisher = Just("Hello, Combine!")

// Subscribe to receive values
let cancellable = publisher
    .sink { value in
        print(value) // "Hello, Combine!"
    }
                                    

Key Combine Concepts

📡

Publishers

Sources that emit values over time

let publisher = Just(42)
let arrayPublisher = [1, 2, 3].publisher
📥

Subscribers

Receivers that consume published values

publisher.sink { value in
    print("Received: \(value)")
}
⚙️

Operators

Transform and filter published values

publisher
    .map { $0 * 2 }
    .filter { $0 > 10 }
🎯

Subjects

Publishers you can send values to

let subject = PassthroughSubject()
subject.send("Hello")

🔹 Basic Publishers and Subscribers

Create and consume data streams with publishers and subscribers:

import Combine

// Simple publishers
let justPublisher = Just("Single Value")
let arrayPublisher = [1, 2, 3, 4, 5].publisher
let rangePublisher = (1...10).publisher

// Basic subscription with sink
var cancellables = Set()

arrayPublisher
    .sink { value in
        print("Array value: \(value)")
    }
    .store(in: &cancellables)

// Subscription with completion handling
rangePublisher
    .sink(
        receiveCompletion: { completion in
            switch completion {
            case .finished:
                print("Range publisher finished")
            case .failure(let error):
                print("Error: \(error)")
            }
        },
        receiveValue: { value in
            print("Range value: \(value)")
        }
    )
    .store(in: &cancellables)

// Timer publisher
let timerPublisher = Timer.publish(every: 1.0, on: .main, in: .common)
    .autoconnect()

timerPublisher
    .sink { date in
        print("Timer tick: \(date)")
    }
    .store(in: &cancellables)

🔹 Subjects for Manual Control

Use subjects to manually send values to subscribers:

import Combine

var cancellables = Set()

// PassthroughSubject - doesn't hold current value
let passthroughSubject = PassthroughSubject()

passthroughSubject
    .sink { value in
        print("Passthrough received: \(value)")
    }
    .store(in: &cancellables)

// Send values
passthroughSubject.send("First message")
passthroughSubject.send("Second message")

// CurrentValueSubject - holds and replays current value
let currentValueSubject = CurrentValueSubject(0)

currentValueSubject
    .sink { value in
        print("Current value: \(value)")
    }
    .store(in: &cancellables)

// Update the current value
currentValueSubject.send(10)
currentValueSubject.send(20)

// Access current value directly
print("Direct access: \(currentValueSubject.value)")

// Complete the subjects
passthroughSubject.send(completion: .finished)
currentValueSubject.send(completion: .finished)

🔹 Operators for Data Transformation

Transform and filter data streams with operators:

import Combine

var cancellables = Set()

// Map - transform values
[1, 2, 3, 4, 5].publisher
    .map { $0 * $0 } // Square each number
    .sink { print("Squared: \($0)") }
    .store(in: &cancellables)

// Filter - only pass certain values
(1...10).publisher
    .filter { $0 % 2 == 0 } // Only even numbers
    .sink { print("Even: \($0)") }
    .store(in: &cancellables)

// CompactMap - transform and remove nils
["1", "2", "abc", "4", "xyz"].publisher
    .compactMap { Int($0) } // Convert to Int, remove failures
    .sink { print("Valid number: \($0)") }
    .store(in: &cancellables)

// Reduce - combine all values into one
[1, 2, 3, 4, 5].publisher
    .reduce(0, +) // Sum all values
    .sink { print("Sum: \($0)") }
    .store(in: &cancellables)

// Scan - running total
[1, 2, 3, 4, 5].publisher
    .scan(0, +) // Running sum
    .sink { print("Running sum: \($0)") }
    .store(in: &cancellables)

// RemoveDuplicates - filter out consecutive duplicates
[1, 1, 2, 2, 2, 3, 3, 1].publisher
    .removeDuplicates()
    .sink { print("Unique: \($0)") }
    .store(in: &cancellables)

// Debounce - wait for pause in values
let subject = PassthroughSubject()

subject
    .debounce(for: .milliseconds(500), scheduler: RunLoop.main)
    .sink { print("Debounced: \($0)") }
    .store(in: &cancellables)

// Simulate rapid input
subject.send("a")
subject.send("ab")
subject.send("abc") // Only this will be printed after 500ms

🔹 Combining Multiple Publishers

Work with multiple data streams simultaneously:

import Combine

var cancellables = Set()

// CombineLatest - combine latest values from multiple publishers
let publisher1 = PassthroughSubject()
let publisher2 = PassthroughSubject()

Publishers.CombineLatest(publisher1, publisher2)
    .sink { text, number in
        print("Combined: \(text) - \(number)")
    }
    .store(in: &cancellables)

publisher1.send("Hello")
publisher2.send(1) // Prints: "Combined: Hello - 1"
publisher1.send("World")
publisher2.send(2) // Prints: "Combined: World - 2"

// Zip - pair values in order
let names = ["Alice", "Bob", "Charlie"].publisher
let ages = [25, 30, 35].publisher

Publishers.Zip(names, ages)
    .sink { name, age in
        print("\(name) is \(age) years old")
    }
    .store(in: &cancellables)

// Merge - combine values from multiple publishers of same type
let publisher3 = PassthroughSubject()
let publisher4 = PassthroughSubject()

Publishers.Merge(publisher3, publisher4)
    .sink { value in
        print("Merged: \(value)")
    }
    .store(in: &cancellables)

publisher3.send("From publisher 3")
publisher4.send("From publisher 4")

// FlatMap - transform each value into a new publisher
["apple", "banana", "cherry"].publisher
    .flatMap { fruit in
        Just(fruit.uppercased())
    }
    .sink { print("Uppercase: \($0)") }
    .store(in: &cancellables)

🔹 Error Handling

Handle errors gracefully in your data streams:

import Combine

enum NetworkError: Error {
    case invalidURL
    case noData
    case decodingError
}

var cancellables = Set()

// Publisher that might fail
let faultyPublisher = PassthroughSubject()

// Catch errors and provide fallback
faultyPublisher
    .catch { error in
        Just("Error occurred: \(error)")
    }
    .sink { value in
        print("Received: \(value)")
    }
    .store(in: &cancellables)

// Send error
faultyPublisher.send(completion: .failure(.noData))

// Retry on failure
let retryPublisher = PassthroughSubject()
var attemptCount = 0

retryPublisher
    .map { value -> Int in
        attemptCount += 1
        if attemptCount < 3 {
            throw NetworkError.noData
        }
        return value
    }
    .retry(2) // Retry up to 2 times
    .catch { _ in Just(-1) } // Fallback value
    .sink { value in
        print("Final value: \(value)")
    }
    .store(in: &cancellables)

retryPublisher.send(42)

// ReplaceError - replace any error with a value
let errorPronePublisher = Fail(error: .invalidURL)

errorPronePublisher
    .replaceError(with: "Default value")
    .sink { value in
        print("Safe value: \(value)")
    }
    .store(in: &cancellables)

🔹 Combine with SwiftUI

Integrate Combine with SwiftUI for reactive UIs:

import SwiftUI
import Combine

class SearchViewModel: ObservableObject {
    @Published var searchText = ""
    @Published var results: [String] = []
    @Published var isLoading = false
    
    private var cancellables = Set()
    
    init() {
        // Reactive search with debouncing
        $searchText
            .debounce(for: .milliseconds(500), scheduler: RunLoop.main)
            .removeDuplicates()
            .sink { [weak self] searchText in
                self?.performSearch(searchText)
            }
            .store(in: &cancellables)
    }
    
    private func performSearch(_ query: String) {
        guard !query.isEmpty else {
            results = []
            return
        }
        
        isLoading = true
        
        // Simulate API call
        Just(query)
            .delay(for: .seconds(1), scheduler: RunLoop.main)
            .map { query in
                // Mock search results
                ["Result 1 for \(query)", "Result 2 for \(query)", "Result 3 for \(query)"]
            }
            .sink { [weak self] searchResults in
                self?.results = searchResults
                self?.isLoading = false
            }
            .store(in: &cancellables)
    }
}

struct SearchView: View {
    @StateObject private var viewModel = SearchViewModel()
    
    var body: some View {
        NavigationView {
            VStack {
                TextField("Search...", text: $viewModel.searchText)
                    .textFieldStyle(RoundedBorderTextFieldStyle())
                    .padding()
                
                if viewModel.isLoading {
                    ProgressView("Searching...")
                        .padding()
                } else {
                    List(viewModel.results, id: \.self) { result in
                        Text(result)
                    }
                }
                
                Spacer()
            }
            .navigationTitle("Search")
        }
    }
}

🔹 Combine Best Practices

Guidelines for effective Combine usage:

✅ Do:

  • Store cancellables to prevent memory leaks
  • Use weak self in closures to avoid retain cycles
  • Debounce user input to avoid excessive API calls
  • Handle errors gracefully with catch and replaceError
  • Use @Published for reactive properties in ObservableObject

❌ Don't:

  • Forget to store cancellables - subscriptions will be cancelled immediately
  • Create retain cycles with strong self references
  • Overuse Combine for simple, one-time operations
  • Ignore error handling in publishers that can fail

🧠 Test Your Knowledge

What do you need to store to keep a Combine subscription active?