The author is available for consulting & contract workBook a call →
Skip to content

Time Operators ​

Time-based operators control when values are propagated, helping you handle rapid changes and timing-sensitive operations.

debounce() ​

Delays propagation of values until a pause occurs. Perfect for handling rapid user input like search fields.

Available on Both Types

debounce() works on both ValueListenable<T> (returns debounced values) and regular Listenable (just debounces notifications without values).

Basic Usage (ValueListenable) ​

dart
void main() async {
  final searchTerm = ValueNotifier<String>('');

  // Debounce search input - only calls API after 500ms pause
  searchTerm
      .debounce(const Duration(milliseconds: 500))
      .listen((s, _) => callRestApi(s));

  // Rapid typing - each keystroke updates the value
  searchTerm.value = 'f';
  searchTerm.value = 'fl';
  searchTerm.value = 'flu';
  searchTerm.value = 'flut';
  searchTerm.value = 'flutt';
  searchTerm.value = 'flutte';
  searchTerm.value = 'flutter';

  // Only after 500ms pause, the API is called with 'flutter'
  // Output (after 500ms): API called with: flutter

  // Wait to see the output
  await Future.delayed(Duration(milliseconds: 600));
}

How It Works ​

debounce() creates a timer that resets on each value change. The value is only propagated when the timer completes without being reset:

dart
final input = ValueNotifier<String>('');

final debounced = input.debounce(Duration(milliseconds: 500));

debounced.listen((value, _) => print('Debounced: $value'));

input.value = 'a';  // Timer starts
input.value = 'ab'; // Timer resets
input.value = 'abc'; // Timer resets
// ... 500ms pause ...
// Prints: "Debounced: abc" (only after pause)

Common Use Cases ​

Search Input

The most common use case - avoid excessive API calls while typing:

dart
final searchTerm = ValueNotifier<String>('');

searchTerm
    .debounce(const Duration(milliseconds: 300))
    .where((term) => term.length >= 3)
    .listen((term, _) => performSearch(term));
Auto-Save

Save user input after they stop typing:

dart
final documentContent = ValueNotifier<String>('');

documentContent
    .debounce(const Duration(seconds: 2))
    .listen((content, _) => autoSave(content));
Form Validation

Validate input after user stops typing:

dart
final emailInput = ValueNotifier<String>('');

emailInput
    .debounce(const Duration(milliseconds: 500))
    .listen((email, _) => validateEmail(email));
Resize Handling

Handle window resize events without overwhelming the system:

dart
final windowSize = ValueNotifier<Size>(Size.zero);

windowSize
    .debounce(const Duration(milliseconds: 200))
    .listen((size, _) => recalculateLayout(size));

Choosing the Right Duration ​

DurationUse Case
100-200msFast feedback (e.g., live preview, instant search)
300-500msStandard user input (e.g., search, validation)
1-2sAuto-save, background operations
3-5sHeavy operations, network calls

Performance Benefits ​

Without debounce:

dart
// User types "flutter" (7 keystrokes)
// Without debounce: 7 API calls!
searchInput.listen((term, _) => searchApi(term));

// Calls: 'f', 'fl', 'flu', 'flut', 'flutt', 'flutte', 'flutter'

With debounce:

dart
// User types "flutter" (7 keystrokes)
// With debounce: 1 API call!
searchInput
    .debounce(Duration(milliseconds: 300))
    .listen((term, _) => searchApi(term));

// Only calls once with: 'flutter'

Using with Regular Listenable ​

For regular Listenable (not ValueListenable), debounce() delays notifications without tracking values:

dart
final notifier = ChangeNotifier();

final debounced = notifier.debounce(Duration(milliseconds: 500));

debounced.listen((_) {
  print('Debounced notification!');
});

// Rapid notifications
notifier.notifyListeners();
notifier.notifyListeners();
notifier.notifyListeners();

// Only one notification after 500ms pause

This is useful when you have a ChangeNotifier or custom Listenable and want to reduce the frequency of notifications without needing to track specific values.

Chaining with Other Operators ​

Debounce works great in operator chains:

dart
final searchInput = ValueNotifier<String>('');

searchInput
    .debounce(Duration(milliseconds: 300))  // Wait for typing pause
    .where((term) => term.length >= 3)       // Minimum length
    .map((term) => term.trim())              // Clean up
    .listen((term, _) => performSearch(term));

Caveats ​

setState and debounce

Using debounce() inside a widget's build method with setState can cause issues because the debounce creates a new chain object on each rebuild, losing the timer state.

❌️ DON'T:

dart
Widget build(BuildContext context) {
  return ValueListenableBuilder(
    valueListenable: input.debounce(Duration(milliseconds: 300)), // NEW DEBOUNCE EACH BUILD!
    builder: (context, value, _) => Text(value),
  );
}

✅ BETTER: Create chain outside build

dart
// Create debounced chain as field
late final debounced = input.debounce(Duration(milliseconds: 300));

Widget build(BuildContext context) {
  return ValueListenableBuilder(
    valueListenable: debounced, // Same debounce every build
    builder: (context, value, _) => Text(value),
  );
}

✅ BEST: Use watch_it with get_it

dart
class SearchWidget extends WatchingWidget {
  @override
  Widget build(BuildContext context) {
    // watch_it caches the chain automatically when using watchValue
    final debouncedTerm = watchValue(
      (SearchModel m) => m.searchTerm
          .debounce(Duration(milliseconds: 300))
          .where((term) => term.length >= 3)
    );

    return Text('Search: $debouncedTerm');
  }
}

// Register SearchModel in get_it
class SearchModel {
  final searchTerm = ValueNotifier<String>('');
}

When to Use debounce() ​

Use debounce() when:

  • ✅ You have rapid value changes (user typing, scrolling, resizing)
  • ✅ You want to reduce API calls or expensive operations
  • ✅ You only care about the "final" value after changes stop
  • ✅ You're implementing search, auto-save, or validation

async() ​

Defers updates to the next frame, preventing "setState called during build" errors.

Basic Usage ​

dart
final source = ValueNotifier<int>(0);

final asyncSource = source.async();

// Updates are deferred to next frame
asyncSource.listen((value, _) => setState(() => _data = value));

How It Works ​

async() uses scheduleMicrotask() to defer the notification until after the current frame completes. This prevents issues when setting state during widget builds.

When to Use async() ​

Use async() when:

  • ✅ You need to call `setState()` from a listener
  • ✅ You're getting "setState called during build" errors
  • ✅ You want to batch multiple synchronous changes

TIP

In most cases, using watch_it is a better solution than async(). watch_it handles state updates automatically without requiring async deferral.

Real-World Example ​

Complete search implementation with debounce:

dart
class SearchViewModel {
  final searchTerm = ValueNotifier<String>('');
  final results = ListNotifier<SearchResult>();
  final isSearching = ValueNotifier<bool>(false);

  SearchViewModel() {
    searchTerm
        .debounce(Duration(milliseconds: 300))
        .where((term) => term.length >= 3)
        .listen((term, _) => _performSearch(term));
  }

  Future<void> _performSearch(String term) async {
    isSearching.value = true;
    try {
      final apiResults = await searchApi(term);
      results.startTransAction();
      results.clear();
      results.addAll(apiResults);
      results.endTransAction();
    } finally {
      isSearching.value = false;
    }
  }
}

Next Steps ​

Released under the MIT License. Built by Thomas Burkhart.