Combine is Apple's framework for handling values that change over time, such as text typed into a field, a stream of network results, or several inputs that must be checked together. It arrived in 2019, before async/await. Today, new code often uses async/await and @Observable instead, but Combine is still all over existing code bases, and the job posts that list *Combine* mean it. This lesson teaches you to read and write it, and to know when not to.
The three words: publisher, operator, subscriber
- A publisher produces values over time, and may finish or fail.
- An operator changes the values on their way:
map,filter,debounce,removeDuplicates. - A subscriber receives the values. The simplest is
sink, which runs a closure for each value.
Think of a pipe: a source, some filters in the middle, and a tap at the end. Nothing flows until a subscriber attaches.
import Combine
let subject = PassthroughSubject<String, Never>()
var cancellables = Set<AnyCancellable>()
subject
.debounce(for: .milliseconds(300), scheduler: DispatchQueue.main)
.removeDuplicates()
.filter { $0.count >= 2 }
.sink { text in
print("search for", text)
}
.store(in: &cancellables)
subject.send("s") // ignored: too short
subject.send("sw")
subject.send("swi") // only the last value after 300 ms is delivered
PassthroughSubject is a publisher you can push values into by hand with send. The two type words say *the values are Strings* and *it never fails* (Never). debounce waits until values stop for a moment, which is the classic way to avoid a search call on every key press. sink returns an AnyCancellable. If you let it go, the subscription ends, so you keep it in a set with store(in:).
@Published and ObservableObject
Before the @Observable macro, SwiftUI view models were classes that conformed to ObservableObject, with @Published properties. Every @Published property is a Combine publisher. You reach it with a dollar sign, and you can build pipes on top of it.
import Combine
@MainActor
final class ExpenseFormModel: ObservableObject {
@Published var title = ""
@Published var amountText = ""
@Published private(set) var isValid = false
init() {
Publishers.CombineLatest($title, $amountText)
.map { title, amountText in
!title.trimmingCharacters(in: .whitespaces).isEmpty
&& (Double(amountText) ?? 0) > 0
}
.assign(to: &$isValid)
}
}
CombineLatest waits until each publisher has a value, then emits a new pair each time either one changes. The map turns each pair into one Bool, and assign(to: &$isValid) feeds it into the published property. The form is always up to date, with no manual code that runs on each change. In a view you use @StateObject private var model = ExpenseFormModel() to own such a class, where the newer @Observable classes use @State.
Networking with Combine
import Combine
import Foundation
func weatherPublisher(url: URL) -> AnyPublisher<Weather, Error> {
URLSession.shared.dataTaskPublisher(for: url)
.map(\.data)
.decode(type: Weather.self, decoder: JSONDecoder())
.receive(on: DispatchQueue.main)
.eraseToAnyPublisher()
}
var bag = Set<AnyCancellable>()
weatherPublisher(url: someURL)
.sink(receiveCompletion: { completion in
if case .failure(let error) = completion { print("failed:", error) }
}, receiveValue: { weather in
print(weather.current.temperature)
})
.store(in: &bag)
receive(on:)moves delivery to the main queue, which you need before touching the screen.eraseToAnyPublisher()hides the long real type behind one simple name, so you can return it from a function.- A publisher can fail, and the failure arrives in
receiveCompletion. After a failure the pipe is finished.
Combine or async/await?
Both can describe asynchronous work, and the choice is about the shape of the problem.
- One answer, later (a network call, a database read): use
async/await. It reads like normal code, and errors usetryandcatch. - Many values over time, to be combined or timed (typing with a pause, several inputs merged): Combine operators are strong here, and the same job in async code often needs more hand-written work. Swift also has
AsyncSequencefor streams, but fewer ready-made operators. - Existing code: if the project uses Combine, follow it. Rewriting working code for fashion is a risk with little gain.
- You can move between them. A publisher has a
.valuesproperty, sofor await value in subject.values { }works, andAsyncStreamcan feed a publisher.
Common mistakes
- A
sinkthat is not stored. The subscription ends at once, and nothing happens. - Updating the screen without
receive(on: DispatchQueue.main). - A
sinkthat usesselfand is stored inself's cancellables, without[weak self]. This is a retain cycle from lesson 3. - Very long chains of operators that nobody can read. Split them and name the steps.
Your task
- Type the debounce example in a Playground or a small app and check which values get through.
- Build
ExpenseFormModeland show a Save button that is disabled whileisValidis false. - Write one
Publisherfor a URL as above, and also the same call withasync/await. Compare the two in a few sentences. - Use
.valuesto read aPassthroughSubjectwithfor await.
How to check it worked
- Only the last value after a pause reaches the
sink. - The Save button turns on and off as you type.
- You can say when you would pick async/await and when Combine.