Combine Framework: Reactief Programmeren in Swift
Beheers Combine voor het verwerken van asynchrone datastromen in Swift: Publishers, Subscribers, Operators en geavanceerde patronen voor iOS-apps.

Reactief programmeren verandert grondig hoe asynchrone events en datastromen in iOS-applicaties worden verwerkt. Combine, het native framework van Apple, biedt een declaratieve en type-safe aanpak om complexe datapijplijnen te orkestreren. Deze gids loopt van de basisconcepten tot productieklare patronen.
Combine is geïntegreerd in iOS 13+, levert betere prestaties dankzij Apples optimalisaties en sluit naadloos aan op SwiftUI. Geen externe afhankelijkheden om te beheren.
Kernconcepten van Combine
Combine is opgebouwd uit drie kernconcepten: Publishers die waarden uitzenden, Subscribers die ze ontvangen en Operators die de data ertussenin transformeren. Deze architectuur maakt het mogelijk om reactieve, samen te stellen datapijplijnen te bouwen.
Publisher: de databron
Een Publisher is een type dat een reeks waarden in de tijd kan uitzenden. Elke Publisher declareert twee bijbehorende types: het type van de uitgezonden waarde (Output) en het type van de mogelijke fout (Failure). Zo maak je verschillende soorten 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"))
}
}Het type Never voor fouten betekent dat de Publisher nooit kan falen. Dat is een garantie tijdens compileren die de foutafhandelingscode vereenvoudigt.
Subscriber: waarden ontvangen
Een Subscriber abonneert zich op een Publisher om diens waarden te ontvangen. De methode sink is de meest gebruikelijke manier om een Subscriber te maken. Hij neemt twee closures: één voor fouten of voltooiing en één voor elke ontvangen waarde:
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 successfullyBewaar altijd de AnyCancellable die sink() teruggeeft. Zonder referentie wordt het abonnement automatisch geannuleerd en wordt geen enkele waarde ontvangen.
Data transformeren met Operators
Operators vormen het hart van Combine. Ze maken het mogelijk om datastromen declaratief te transformeren, te filteren en te combineren. Elke Operator levert een nieuwe Publisher op, waardoor ze gekoppeld kunnen worden.
Essentiële transformatie-Operators
Transformatie-Operators wijzigen elke uitgezonden waarde. map transformeert waarden, flatMap plat geneste Publishers af en compactMap filtert nil-waarden eruit:
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)Filter-Operators
Filter-Operators bepalen welke waarden de pijplijn doorlopen. Ze zijn essentieel om onnodige verwerking te vermijden en de prestaties te optimaliseren:
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 300msKlaar om je iOS gesprekken te halen?
Oefen met onze interactieve simulatoren, flashcards en technische tests.
Meerdere Publishers combineren
Reële applicaties moeten vaak verschillende databronnen samenbrengen. Combine biedt diverse Operators om deze meerdere stromen te orkestreren.
CombineLatest en Zip
combineLatest zendt uit zodra eender welke Publisher een waarde uitzendt en combineert met de laatste waarden van de anderen. zip wacht tot alle Publishers hebben uitgezonden voordat het combineert:
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 om stromen te bundelen
merge voegt meerdere Publishers van hetzelfde type samen tot één enkele stroom. De waarden komen aan in volgorde van uitzending, ongeacht welke Publisher ze stuurde:
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!Foutafhandeling in Combine
Foutafhandeling zit ingebakken in de kern van Combine. Het type Failure van Publishers laat de compiler controleren of alle fouten worden afgehandeld.
Herstelstrategieën
Combine biedt diverse Operators om fouten af te handelen: catch om te vervangen door een andere Publisher, retry om opnieuw te proberen en replaceError voor een standaardwaarde:
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)Gebruik setFailureType(to:) om een Never-Publisher om te zetten naar één die kan falen, en replaceError(with:) of catch voor de omgekeerde richting.
MVVM-patroon met Combine
Combine integreert van nature met het MVVM-patroon (Model-View-ViewModel). Het ViewModel stelt Publishers ter beschikking die de View observeert, waardoor een reactieve binding ontstaat tussen data en interface.
Volledig reactief ViewModel
Hier een voorbeeld van een ViewModel voor een gebruikerslijst met zoekopdracht, laadtoestand en foutafhandeling:
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 met Combine en URLSession
URLSession integreert Combine native via dataTaskPublisher. Zo bouw je een herbruikbare netwerkservice:
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()
}
}Klaar om je iOS gesprekken te halen?
Oefen met onze interactieve simulatoren, flashcards en technische tests.
SwiftUI-integratie
Combine en SwiftUI vormen een krachtig duo. De @Published-properties van een ObservableObject triggeren automatisch view-updates.
SwiftUI-view met Combine-ViewModel
Zo koppel je het ViewModel aan een 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)
}
}Geavanceerde patronen
Annulering en automatische opruiming
De levenscyclus van abonnementen beheren is cruciaal om memory leaks te voorkomen. Het cancellables-patroon met AnyCancellable zorgt voor automatische opruiming:
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 voor threading
Schedulers bepalen op welke thread bewerkingen draaien. Gebruik subscribe(on:) voor achtergrondwerk en receive(on:) voor 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()
}Conclusie
Combine biedt een krachtige en declaratieve aanpak voor het verwerken van asynchrone datastromen in iOS-apps. Belangrijkste punten:
✅ Publishers zenden waarden uit in de tijd
✅ Subscribers ontvangen en verwerken die waarden
✅ Operators transformeren en combineren stromen
✅ AnyCancellable beheert de levenscyclus van abonnementen
✅ @Published met SwiftUI maakt automatische reactieve bindings
✅ Schedulers sturen threading aan voor optimale prestaties
Wie Combine beheerst, bouwt robuuste, onderhoudbare en reactieve iOS-apps. De native integratie met SwiftUI maakt het tot een onmisbaar gereedschap voor moderne iOS-ontwikkeling.
Begin met oefenen!
Test je kennis met onze gespreksimulatoren en technische tests.
Tags
Delen
Gerelateerde artikelen

Combine vs async/await in Swift: Progressieve Migratiepatronen
Volledige gids voor migratie van Combine naar async/await in Swift: progressieve strategieën, bridging-patronen en paradigma-coëxistentie in iOS-codebases.

iOS-toegankelijkheidsvragen voor sollicitaties in 2026: VoiceOver en Dynamic Type
Bereid je voor op iOS-sollicitaties met essentiële toegankelijkheidsvragen: VoiceOver, Dynamic Type, semantische traits en audits.

Swift Macros: praktische voorbeelden van metaprogrammering
Volledige gids over Swift Macros: het maken van freestanding- en attached-macro's, AST-manipulatie met swift-syntax en praktische voorbeelden om boilerplate-code te elimineren.