How to use Debounce in SwiftUI or in Observable classes


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 an actor, 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.