Skip to content
← All Field Notes

SwiftUI

Cancel stale search requests in SwiftUI

Debounce search with task(id:), guard UI updates after an async call, and test what happens when responses arrive out of order.

Oleg Yakushin

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

  1. 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.
  2. Repeat with A failing after B succeeds. Confirm B remains visible and the old error does not appear.
  3. 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.
  4. 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.