iosNudge

Search with Debounce

MediumSwiftUISwiftUIConcurrencyNetworking 40 min read

Build a country search screen. The user types in a search bar. The app asks a (fake) server for matches. Do not send a request for every key press.

Debounce means: wait until the user stops typing for a short time, then do the work. Here the time is 300 ms.

protocol SearchService: Sendable {
    func search(_ query: String) async throws -> [Country]
}

// FakeSearchService waits 0.2–0.8 s (random), so answers can arrive out of order.
// The query "error" throws SearchError.server.

Example

The user types j, ja, jap quickly, then stops.

0 ms    "j"     -> wait...
80 ms   "ja"    -> cancel the "j" wait, wait again...
150 ms  "jap"   -> cancel the "ja" wait, wait again...
450 ms          -> 300 ms passed: search("jap")  (only ONE request)
900 ms          -> show "Japan"

Requirements

  • Use .searchable inside a NavigationStack.
  • Wait 300 ms after the last key press before you search.
  • Cancel the old search when the text changes. Use .task(id:).
  • An old, slow answer must never replace a newer one.
  • Trim spaces. An empty query shows a start screen right away (no waiting).
  • Show these states:
    • idle: "Search Countries" with a hint
    • loading: a spinner
    • results: a list with name and capital
    • empty: "No Results for 'xyz'"
    • error: a message and a "Try Again" button
  • "Try Again" searches the same text again.

What a good answer handles

  • Out-of-order answers. "ja" can come back after "jap". Prove your code drops it.
  • Cancellation. Leaving the screen stops the running search.
  • No timers or Combine needed. Explain why .task(id:) + Task.sleep is enough.
  • VoiceOver. The list changes without the focus moving. Tell the user how many results there are.
  • Testability. The view model gets the service and the debounce time through init.

Notes

  • Target iOS 17 or later. Use @Observable, .task(id:) and ContentUnavailableView.
  • No real network. Use FakeSearchService.

Starter code

Copy this into a new Xcode project (or Swift Playgrounds) and build from here.

import SwiftUI

// MARK: - Model and service (done for you)

struct Country: Identifiable, Hashable, Sendable {
    let name: String
    let capital: String
    var id: String { name }
}

enum SearchError: LocalizedError {
    case server

    var errorDescription: String? {
        "The server is not responding. Try again."
    }
}

protocol SearchService: Sendable {
    func search(_ query: String) async throws -> [Country]
}

/// Fake backend. It waits 0.2–0.8 seconds, so answers can come back out of order.
/// Type "error" to see the error state.
struct FakeSearchService: SearchService {
    func search(_ query: String) async throws -> [Country] {
        try await Task.sleep(for: .milliseconds(Int.random(in: 200...800)))
        if query.lowercased() == "error" { throw SearchError.server }
        return Country.all.filter { $0.name.localizedCaseInsensitiveContains(query) }
    }
}

extension Country {
    static let all: [Country] = [
        Country(name: "Argentina", capital: "Buenos Aires"),
        Country(name: "Australia", capital: "Canberra"),
        Country(name: "Austria", capital: "Vienna"),
        Country(name: "Brazil", capital: "Brasília"),
        Country(name: "Canada", capital: "Ottawa"),
        Country(name: "France", capital: "Paris"),
        Country(name: "Germany", capital: "Berlin"),
        Country(name: "India", capital: "New Delhi"),
        Country(name: "Japan", capital: "Tokyo"),
        Country(name: "Kenya", capital: "Nairobi"),
        Country(name: "Mexico", capital: "Mexico City"),
        Country(name: "Norway", capital: "Oslo"),
        Country(name: "Spain", capital: "Madrid"),
        Country(name: "United Kingdom", capital: "London"),
        Country(name: "United States", capital: "Washington, D.C."),
    ]
}

// MARK: - View model

@MainActor
@Observable
final class SearchViewModel {
    var query = ""
    private(set) var results: [Country] = []
    // TODO: model the states: idle, loading, results, empty, failed.

    private let service: any SearchService

    init(service: any SearchService = FakeSearchService()) {
        self.service = service
    }

    func search() async {
        // TODO: wait 300 ms first (debounce), then call the service.
        // Old searches must never overwrite newer results.
    }
}

// MARK: - View

struct CountrySearchView: View {
    @State private var model = SearchViewModel()

    var body: some View {
        NavigationStack {
            List(model.results) { country in
                Text(country.name)
            }
            .navigationTitle("Countries")
            .searchable(text: $model.query, prompt: "Search countries")
            // TODO: start a search when the query changes. Hint: .task(id:)
            // TODO: show idle, loading, empty and error states.
        }
    }
}

#Preview {
    CountrySearchView()
}

Solution

Try to build it first. Then compare your design with ours.