Greetings, traveler!
Debouncing is a core technique in responsive UI design — whether you’re delaying API calls while a user types, or limiting frequent updates in real time. For years, Combine’s .debounce has been the go-to solution for Swift developers. But in today’s SwiftUI world, where the Observation framework is replacing @Published and Combine is slowly being phased out, we need modern tools that play well with Swift Concurrency.
This post will discuss a generic, elegant, and actor-based debouncer that lets you wrap any function and automatically debounce it using a single line of code.
In legacy SwiftUI, debouncing usually looked like this:
import Combine
final class ViewModel: ObservableObject {
@Published var searchText: String = ""
private var subscriptions = Set<AnyCancellable>()
func bind() {
$searchText
.debounce(for: .milliseconds(300), scheduler: RunLoop.main)
.sink { value in
// Perform search
}
.store(in: &subscriptions)
}
}However, this approach relies on @Published, which isn’t compatible with the new SwiftUI Observation system (@Observable, @Bindable, etc.). Since Combine is no longer the preferred solution, we need something that:
- Works with async/await
- Doesn’t rely on Combine
- Plays nicely with
@Observablemodels - Is simple and reusable
A Generic Debouncer Actor
We will create a Debounce actor. Here’s the gist of what it does:
- You wrap an async function inside a
Debounceinstance. - Every call is debounced: only the last invocation is actually executed, after a specified delay.
- It uses Swift’s
Task.sleepfor timing and cancellation for clean behavior.
Example Usage
Consider this example:
@Observable
final class ViewModel {
var searchText: String = ""
@Sendable
func search() {
print("Perform search API call")
}
func perform() {
let delayedSearch = Debounce(search, for: .milliseconds(400))
Task {
Task {
await delayedSearch()
}
try await Task.sleep(for: .milliseconds(100))
Task {
await delayedSearch()
}
try await Task.sleep(for: .milliseconds(100))
Task {
await delayedSearch()
}
}
}
}The API call will be executed only once, after 400 ms. We can cancel the pending debounce at any time:
await delayedSearch.cancel()So, let’s take a closer look at an actor which can help us to create such behavior.
Debounce actor
Here’s a breakdown of how it works:
Debounceis defined as anactor, which ensures thread safety.- It stores the target function and a configurable
Duration. - Every call cancels the previous
Taskand creates a new one that executes after a delay. - If the delay is interrupted by cancellation, the function is not called.
actor Debounce<each Parameter: Sendable>: Sendable {
private let action: @Sendable (repeat each Parameter) async -> Void
private let delay: Duration
private var task: Task<Void, Never>?
init(
_ action: @Sendable @escaping (repeat each Parameter) async -> Void,
for dueTime: Duration
) {
delay = dueTime
self.action = action
}
}extension Debounce {
func callAsFunction(_ parameter: repeat each Parameter) {
task?.cancel()
task = Task {
try? await Task.sleep(for: delay)
guard !Task.isCancelled else { return }
await action(repeat each parameter)
}
}
func cancel() {
task?.cancel()
task = nil
}
}Thanks to Swift’s parameter packs, Debounce can debounce functions with any number of parameters:
@Sendable
func logEvent(_ category: String, _ action: String) async {
print("Logged \(category) - \(action)")
}
let logDebounced = Debounce(logEvent, for: .milliseconds(500))
await logDebounced("UI", "Tap")
await logDebounced("UI", "Tap")To ensure thread safety, Debounce marks the wrapped function as @Sendable, which guarantees that the closure doesn’t capture any non-thread-safe data. Since the debounced function is executed inside an isolated Task within an actor, Swift requires this annotation to prevent potential data races. As a result, any function passed into Debounce should either be explicitly marked @Sendable, or safely wrapped to avoid capturing unsafe references like self from class instances.
SwiftUI View Modifier
To make Debounce easily usable within SwiftUI views, we can create a custom View modifier called onChangeDebounced. It works like SwiftUI’s built-in onChange, but automatically debounces the provided action using a specified duration. This allows to debounce user input—such as keystrokes in a TextField—without relying on Combine or @Published. The modifier also supports optional task cancellation via a Binding, making it flexible enough to handle common UX scenarios like cancelling on escape or forcing immediate execution on return key press.
First, let’s create a ViewModifier.
private struct DebounceModifier<Value: Equatable>: ViewModifier {
typealias Completion = (Value, Value) -> Void
typealias TaskType = Task<Void, Never>
private let value: Value
private let delay: Duration
private let initial: Bool
private let action: Completion
@Binding private var parentTask: TaskType?
@State private var internalTask: TaskType?
@State private var useParentTask: Bool
private var currentTask: Binding<TaskType?> {
Binding<TaskType?>(
get: {
if useParentTask { parentTask } else { internalTask }
},
set: {
if useParentTask { parentTask = $0 } else { internalTask = $0 }
}
)
}
init(
value: Value,
for dueTime: Duration,
task: Binding<TaskType?>?,
initial: Bool,
action: @escaping Completion
) {
self.value = value
delay = dueTime
self.initial = initial
self.action = action
if let task {
_parentTask = task
useParentTask = true
} else {
_parentTask = .constant(nil)
useParentTask = false
}
}
func body(content: Content) -> some View {
content.onChange(
of: value,
initial: initial
) { oldValue, newValue in
currentTask.wrappedValue?.cancel()
currentTask.wrappedValue = Task {
try? await Task.sleep(for: delay)
guard !Task.isCancelled else { return }
action(oldValue, newValue)
currentTask.wrappedValue = nil
}
}
}
}Now, let’s create an extension for View.
extension View {
func onChangeDebounced<Value: Equatable>(
of value: Value,
for dueTime: Duration,
task: Binding<Task<Void, Never>?>? = nil,
initial: Bool = false,
_ action: @escaping (_ oldValue: Value, _ newValue: Value) -> Void
) -> some View {
modifier(
DebounceModifier(
value: value,
for: dueTime,
task: task,
initial: initial,
action: action
)
)
}
}Finally, we can use it like this:
struct ContentView: View {
@State private var searchText: String = ""
var body: some View {
TextField("Search...", text: $searchText)
.onChangeDebounced(of: searchText, for: .milliseconds(400)) { _, newValue in
Task {
await search(newValue)
}
}
}
private func search(_ string: String) async {
print("Perform search API call")
}
}Conclusion
If you are looking for a clean solution for use with an @Observable classes, this approach may be useful. It sheds the weight of Combine, embraces Swift Concurrency, and plays perfectly with the new Observation system in SwiftUI.
