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.