Reproduce the race
Search for “bike”, then change the query to “bell” before the first request returns. If “bell” finishes first, the list should keep those results when the older response arrives. The same race can affect a loading spinner or error message.
Give each query its own task
Use the normalized query as the ID for .task(id:). SwiftUI cancels and recreates the task when the ID changes. The delay gives the user time to type. Checking cancellation after the service returns protects the UI even when that service completes work after cancellation. Apple’s task modifier
import SwiftUI
struct SearchScreen: View {
let search: (String) async throws -> [String]
@State private var query = ""
@State private var phase: Phase = .idle
private enum Phase {
case idle, loading, results([String]), failed
}
var body: some View {
let term = query.trimmingCharacters(in: .whitespacesAndNewlines)
VStack {
TextField("Search", text: $query)
.textFieldStyle(.roundedBorder)
switch phase {
case .idle:
Text("Enter a search term")
case .loading:
ProgressView()
case .results(let matches):
if matches.isEmpty {
Text("No matches")
} else {
Text(matches.joined(separator: ", "))
}
case .failed:
Text("Search failed. Change your search to try again.")
}
}
.task(id: term) { @MainActor in
guard !Task.isCancelled else { return }
guard !term.isEmpty else {
phase = .idle
return
}
phase = .loading
do {
try await Task.sleep(nanoseconds: 300_000_000)
let matches = try await search(term)
try Task.checkCancellation()
phase = .results(matches)
} catch {
guard !Task.isCancelled, !(error is CancellationError) else {
return
}
phase = .failed
}
}
}
}
The service is injected so you can replace it with a stub. The example keeps results and errors in one phase and updates it on the main actor. For several filters, use an equatable value containing all inputs as the task ID, such as the query, category and sort order.
Test the order of responses
- Inject a stub whose completions you control. Start A, change the query to B, then return B before releasing A. Make the stub ignore cancellation for A so the guard gets exercised.
- Repeat with A failing after B succeeds. Confirm B remains visible and the old error does not appear.
- Clear the query during a request and confirm the screen stays idle when the result arrives. Try A → B → A and verify only the final task can publish results.
- Leave the screen while a request is running. On return, verify the visible screen starts its own load.
If requests live in a shared model outside the view task, give each load a generation token and check it before publishing. Comparing query strings alone can accept an old response when the same query is entered twice.