flatMap chains one async operation onto another's result. switchToLatest does the same
but drops results from superseded requests, which is the difference between a correct search
screen and one where a slow old query overwrites the fresh results.
flatMap Plus Debounce
@Published exposes each property as a publisher via $. Piping the query into a search
request is one flatMap; debounce holds the chain until typing pauses so every keystroke
does not become a network call.
final class SearchViewModel: ObservableObject {
@Published var query: String = ""
@Published private(set) var repos: [Repo] = []
private let service = GithubService()
init() {
$query
.debounce(for: 0.5, scheduler: DispatchQueue.main)
.flatMap {
self.service.searchPublisher(matching: $0)
.replaceError(with: [])
}
.receive(on: DispatchQueue.main)
.assign(to: &$repos)
}
}
The Stale-Result Race
With flatMap, every inner publisher stays alive. Type "swift", then "swiftui": if the
first request is slower, its response arrives last and replaces the correct results on
screen. flatMap cannot prevent this.
switchToLatest
switchToLatest consumes a publisher of publishers, so map the query into a search
publisher instead of flat-mapping it. Each new inner publisher cancels the previous one,
and only the latest request's results are delivered.
$query
.debounce(for: 0.5, scheduler: DispatchQueue.main)
.map {
self.service.searchPublisher(matching: $0)
.replaceError(with: [])
}
.switchToLatest()
.receive(on: DispatchQueue.main)
.assign(to: &$repos)
Rule of thumb: flatMap for chaining dependent operations whose every result matters;
map plus switchToLatest for user-driven work where only the latest response should win.