Search & Filter with Debouncing

Searching without debouncing fires API calls on every keystroke. Here's how to implement efficient search with task cancellation.
Follow along with the code: iOS-Practice on GitHub
The Problem
Without debouncing, typing "swift" fires 5 API calls:
- "s" → API call
- "sw" → API call
- "swi" → API call
- "swif" → API call
- "swift" → API call
This wastes resources and can cause race conditions.
The Solution
struct SearchFilterExerciseView: View {
@State private var products: [Product] = []
@State private var searchText = ""
@State private var isLoading = false
@State private var searchTask: Task<Void, Never>?
var body: some View {
List {
// ... content
}
.searchable(text: $searchText, prompt: "Search products...")
.onChange(of: searchText) { _, newValue in
performSearch(query: newValue)
}
.task {
await loadProducts()
}
}
private func performSearch(query: String) {
// Cancel previous search
searchTask?.cancel()
guard !query.isEmpty else {
Task { await loadProducts() }
return
}
searchTask = Task {
// Debounce: wait 300ms before searching
try? await Task.sleep(nanoseconds: 300_000_000)
// Check if cancelled during wait
guard !Task.isCancelled else { return }
isLoading = true
do {
products = try await MockAPIService.shared.searchProducts(query: query)
} catch {
if !Task.isCancelled {
// Handle error
}
}
isLoading = false
}
}
}
Key Components
1. Track the Search Task
@State private var searchTask: Task<Void, Never>?
2. Cancel on New Input
searchTask?.cancel()
3. Debounce with Sleep
try? await Task.sleep(nanoseconds: 300_000_000) // 300ms
4. Check Cancellation
guard !Task.isCancelled else { return }
The .searchable Modifier
SwiftUI provides the search bar:
.searchable(text: $searchText, prompt: "Search products...")
This adds:
- Search bar in navigation
- Keyboard with search button
- Cancel button
- Automatic hiding when scrolling
Responding to Changes
Use .onChange to react to search text:
.onChange(of: searchText) { _, newValue in
performSearch(query: newValue)
}
Empty Search Handling
Clear search → reload all products:
guard !query.isEmpty else {
Task { await loadProducts() }
return
}
Error Handling
Only handle errors if not cancelled:
} catch {
if !Task.isCancelled {
self.error = error
}
}
Cancellation throws CancellationError, which we can ignore.
Interview Tip
This pattern demonstrates understanding of:
- Task cancellation (cooperative)
- Debouncing (300ms is standard)
- Race condition prevention
- Resource efficiency