DEV Community

Cover image for iOS - 3 Line code for Debounce in SwiftUI
ArshTechPro
ArshTechPro

Posted on

iOS - 3 Line code for Debounce in SwiftUI

If you've ever built a search feature in iOS, you know the problem: every keystroke triggers a network request. Type "restaurant" and you've just fired off 10 API calls. Your backend is crying, your users are waiting, and you're wondering why Apple made this so complicated.

Good news: debouncing in SwiftUI doesn't require Combine, custom publishers, or complex state management. There's a native solution that's been hiding in plain sight since iOS 15.

The Problem

Let's say you're building a search screen:

struct SearchView: View {
    @State private var searchText = ""
    @State private var results: [String] = []

    var body: some View {
        TextField("Search...", text: $searchText)
            .onChange(of: searchText) { newValue in
                // This fires on EVERY keystroke
                performSearch(newValue)
            }
    }
}
Enter fullscreen mode Exit fullscreen mode

Type "coffee" and you've triggered 6 searches: "c", "co", "cof", "coff", "coffe", "coffee". That's wasteful, slow, and expensive.

The Old Way: Combine Framework

Traditionally, developers reached for Combine:

import Combine

class SearchViewModel: ObservableObject {
    @Published var searchText = ""
    private var cancellables = Set<AnyCancellable>()

    init() {
        $searchText
            .debounce(for: .milliseconds(500), scheduler: RunLoop.main)
            .sink { [weak self] text in
                self?.performSearch(text)
            }
            .store(in: &cancellables)
    }
}
Enter fullscreen mode Exit fullscreen mode

This works, but it's verbose. You need to import Combine, manage publishers, handle cancellables, and understand reactive programming concepts that feel like overkill for a simple delay.

The New Way: task(id:)

SwiftUI's task(id:) modifier does something clever: when the ID changes, it automatically cancels the previous task and starts a new one. We can use this behavior to create a debounce with just one modifier:

struct SearchView: View {
    @State private var searchText = ""
    @State private var results: [String] = []

    var body: some View {
        TextField("Search...", text: $searchText)
            .task(id: searchText) {
                try? await Task.sleep(nanoseconds: 500_000_000)
                await performSearch(searchText)
            }
    }

    private func performSearch(_ query: String) async {
        // Your search logic here
    }
}
Enter fullscreen mode Exit fullscreen mode

That's it. Three lines of code. No imports, no publishers, no ceremony.

How It Works

Here's what happens when you type "coffee":

  1. User types "c"
  2. Task starts, sleeps for 0.5 seconds
  3. User types "o" before sleep completes
  4. SwiftUI sees searchText changed, cancels the previous task
  5. New task starts, sleeps for 0.5 seconds
  6. This repeats for each keystroke
  7. User stops typing after "coffee"
  8. Final task completes its sleep
  9. Search executes once with "coffee"

The key insight: task(id:) automatically manages task cancellation for you. When the ID changes, the old task dies and a new one begins. The sleep acts as your debounce delay.

Real-World Example

Here's a complete search implementation:

struct SearchView: View {
    @State private var searchText = ""
    @State private var results: [String] = []
    @State private var isSearching = false

    var body: some View {
        VStack {
            TextField("Search...", text: $searchText)
                .textFieldStyle(.roundedBorder)
                .padding()

            if isSearching {
                ProgressView()
            }

            List(results, id: \.self) { result in
                Text(result)
            }
        }
        .task(id: searchText) {
            try? await Task.sleep(nanoseconds: 500_000_000)
            await performSearch(searchText)
        }
    }

    private func performSearch(_ query: String) async {
        guard !query.isEmpty else {
            results = []
            isSearching = false
            return
        }

        isSearching = true

        // Simulate API call
        try? await Task.sleep(nanoseconds: 1_000_000_000)

        // Your actual API call here
        results = await APIService.search(query)

        isSearching = false
    }
}
Enter fullscreen mode Exit fullscreen mode

Important Notes

Minimum iOS Version: This requires iOS 15+ for the task(id:) modifier.

Task Cancellation: The previous task is cancelled automatically. If you're doing cleanup in your search function, handle Task.isCancelled appropriately.

Sleep Duration: 500 milliseconds (0.5 seconds) is a good default. Adjust based on your use case:

  • Fast typists: 300-400ms
  • Network-heavy operations: 600-800ms
  • Real-time feel: 200-300ms

Empty Query Handling: Always check if the query is empty to avoid unnecessary API calls.

Common Mistakes

Don't do this:

.task(id: searchText) {
    performSearch(searchText) // Missing await and debounce delay
}
Enter fullscreen mode Exit fullscreen mode

Don't do this:

.onChange(of: searchText) { _ in
    Task {
        try? await Task.sleep(nanoseconds: 500_000_000)
        performSearch(searchText) // Previous tasks aren't cancelled
    }
}
Enter fullscreen mode Exit fullscreen mode

The second example creates tasks that never get cancelled. You'll end up with multiple searches running simultaneously.

When NOT to Use This

This pattern is perfect for search, validation, and auto-save features. But it's not ideal for:

  • Immediate user feedback (button taps, toggles)
  • Critical real-time updates (chat messages, live scores)
  • Animations or gestures

For those cases, respond immediately.

Conclusion

Debouncing doesn't have to be complicated. SwiftUI gives you the tools to implement it cleanly without external dependencies or complex reactive code.


If you found this helpful, follow me for more iOS development tips and techniques.

Top comments (1)

Collapse
 
arshtechpro profile image
ArshTechPro

task(id:) automatically manages task cancellation for you. When the ID changes, the old task dies and a new one begins. The sleep acts as your debounce delay.