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
.searchableinside aNavigationStack. - 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.sleepis 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:)andContentUnavailableView. - 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.