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
@Observable
models - 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
Debounce
instance. - Every call is debounced: only the last invocation is actually executed, after a specified delay.
- It uses Swift’s
Task.sleep
for 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:
Debounce
is defined as anactor
, which ensures thread safety.- It stores the target function and a configurable
Duration
. - Every call cancels the previous
Task
and 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.
If you enjoyed this article, please feel free to follow me on my social media: