Question #164EasyFlutter Basics

what is debouncing in flutter

#flutter

Answer

Overview

Debouncing is a technique to delay execution of a function until a certain amount of time has passed since the last call. It's commonly used to avoid excessive API calls when user types in a search field.


Problem Without Debouncing

Without debouncing, every keystroke triggers an API call.

dart
TextField(
  onChanged: (query) {
    searchAPI(query); // ❌ Called on EVERY keystroke!
  },
)

Example: User types "flutter"

  • text
    f
    → API call
  • text
    fl
    → API call
  • text
    flu
    → API call
  • text
    flut
    → API call
  • text
    flutt
    → API call
  • text
    flutte
    → API call
  • text
    flutter
    → API call

Result: 7 API calls! (wasteful, slow, expensive)


Solution: Debouncing

Wait 300-500ms after user stops typing, then make API call.

dart
TextField(
  onChanged: (query) {
    debounce(() {
      searchAPI(query); // ✅ Called only after 500ms of no typing
    });
  },
)

Example: User types "flutter"

  • text
    f
    ,
    text
    fl
    ,
    text
    flu
    ,
    text
    flut
    ,
    text
    flutt
    ,
    text
    flutte
    ,
    text
    flutter
    → (user stops typing)
  • Wait 500ms
  • → API call with "flutter"

Result: 1 API call! ✅


Implementation

Method 1: Using Timer

dart
import 'dart:async';

class SearchScreen extends StatefulWidget {
  
  _SearchScreenState createState() => _SearchScreenState();
}

class _SearchScreenState extends State<SearchScreen> {
  Timer? _debounce;

  void _onSearchChanged(String query) {
    // Cancel previous timer
    if (_debounce?.isActive ?? false) _debounce!.cancel();

    // Start new timer
    _debounce = Timer(Duration(milliseconds: 500), () {
      // Execute after 500ms of no typing
      searchAPI(query);
    });
  }

  
  void dispose() {
    _debounce?.cancel(); // Cleanup
    super.dispose();
  }

  
  Widget build(BuildContext context) {
    return TextField(
      onChanged: _onSearchChanged,
      decoration: InputDecoration(
        labelText: 'Search',
        prefixIcon: Icon(Icons.search),
      ),
    );
  }

  Future<void> searchAPI(String query) async {
    print('Searching for: $query');
    // API call here
  }
}

Method 2: Reusable Debounce Class

dart
import 'dart:async';

class Debouncer {
  final Duration duration;
  Timer? _timer;

  Debouncer({this.duration = const Duration(milliseconds: 500)});

  void run(VoidCallback action) {
    _timer?.cancel();
    _timer = Timer(duration, action);
  }

  void dispose() {
    _timer?.cancel();
  }
}

// Usage
class SearchScreen extends StatefulWidget {
  
  _SearchScreenState createState() => _SearchScreenState();
}

class _SearchScreenState extends State<SearchScreen> {
  final _debouncer = Debouncer(duration: Duration(milliseconds: 500));

  
  void dispose() {
    _debouncer.dispose();
    super.dispose();
  }

  
  Widget build(BuildContext context) {
    return TextField(
      onChanged: (query) {
        _debouncer.run(() {
          searchAPI(query);
        });
      },
    );
  }

  Future<void> searchAPI(String query) async {
    print('Searching for: $query');
  }
}

Method 3: Using Stream (RxDart)

yaml
dependencies:
  rxdart: ^0.27.0
dart
import 'package:rxdart/rxdart.dart';

class SearchScreen extends StatefulWidget {
  
  _SearchScreenState createState() => _SearchScreenState();
}

class _SearchScreenState extends State<SearchScreen> {
  final _searchController = TextEditingController();
  final _searchSubject = PublishSubject<String>();

  
  void initState() {
    super.initState();

    // Debounce search stream
    _searchSubject.stream
        .debounceTime(Duration(milliseconds: 500))
        .listen((query) {
      searchAPI(query);
    });
  }

  
  void dispose() {
    _searchController.dispose();
    _searchSubject.close();
    super.dispose();
  }

  
  Widget build(BuildContext context) {
    return TextField(
      controller: _searchController,
      onChanged: (query) {
        _searchSubject.add(query); // Add to stream
      },
      decoration: InputDecoration(
        labelText: 'Search',
        prefixIcon: Icon(Icons.search),
      ),
    );
  }

  Future<void> searchAPI(String query) async {
    print('Searching for: $query');
  }
}

Method 4: Using BLoC

dart
import 'package:flutter_bloc/flutter_bloc.dart';

// Event
class SearchQueryChanged extends SearchEvent {
  final String query;
  SearchQueryChanged(this.query);
}

// BLoC
class SearchBloc extends Bloc<SearchEvent, SearchState> {
  SearchBloc() : super(SearchInitial()) {
    on<SearchQueryChanged>(
      _onSearchQueryChanged,
      transformer: debounce(Duration(milliseconds: 500)),
    );
  }

  Future<void> _onSearchQueryChanged(
    SearchQueryChanged event,
    Emitter<SearchState> emit,
  ) async {
    emit(SearchLoading());
    try {
      final results = await searchAPI(event.query);
      emit(SearchSuccess(results));
    } catch (e) {
      emit(SearchError(e.toString()));
    }
  }
}

// Debounce transformer
EventTransformer<T> debounce<T>(Duration duration) {
  return (events, mapper) => events.debounceTime(duration).flatMap(mapper);
}

Complete Example: Search Screen

dart
import 'dart:async';
import 'package:flutter/material.dart';

class SearchScreen extends StatefulWidget {
  
  _SearchScreenState createState() => _SearchScreenState();
}

class _SearchScreenState extends State<SearchScreen> {
  Timer? _debounce;
  List<String> _results = [];
  bool _isLoading = false;

  void _onSearchChanged(String query) {
    if (_debounce?.isActive ?? false) _debounce!.cancel();

    setState(() {
      _isLoading = true;
    });

    _debounce = Timer(Duration(milliseconds: 500), () async {
      if (query.isEmpty) {
        setState(() {
          _results = [];
          _isLoading = false;
        });
        return;
      }

      // Simulate API call
      final results = await searchAPI(query);

      setState(() {
        _results = results;
        _isLoading = false;
      });
    });
  }

  
  void dispose() {
    _debounce?.cancel();
    super.dispose();
  }

  
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: Text('Search')),
      body: Column(
        children: [
          Padding(
            padding: EdgeInsets.all(16),
            child: TextField(
              onChanged: _onSearchChanged,
              decoration: InputDecoration(
                labelText: 'Search',
                prefixIcon: Icon(Icons.search),
                border: OutlineInputBorder(),
              ),
            ),
          ),
          if (_isLoading)
            CircularProgressIndicator()
          else
            Expanded(
              child: ListView.builder(
                itemCount: _results.length,
                itemBuilder: (context, index) {
                  return ListTile(
                    title: Text(_results[index]),
                  );
                },
              ),
            ),
        ],
      ),
    );
  }

  Future<List<String>> searchAPI(String query) async {
    await Future.delayed(Duration(milliseconds: 500)); // Simulate network delay
    return List.generate(10, (i) => '$query - Result $i');
  }
}

Debouncing vs Throttling

TechniqueBehaviorUse Case
DebouncingWaits until user stops actionSearch input (wait for user to stop typing)
ThrottlingExecutes at fixed intervalsScroll events (execute every 200ms during scroll)

Throttling Example

dart
Timer? _throttle;

void _onScroll() {
  if (_throttle?.isActive ?? false) return; // Skip if already running

  _throttle = Timer(Duration(milliseconds: 200), () {
    print('Scroll event executed');
  });
}

Debounce Duration

DurationUse Case
200-300msFast interactions (autocomplete)
500msStandard search (most common)
1000ms+Expensive operations (complex calculations)

Best Practices

dart
// ✅ Use debouncing for search inputs
TextField(onChanged: (query) => _debouncer.run(() => search(query)))

// ✅ Cancel timers in dispose

void dispose() {
  _debounce?.cancel();
  super.dispose();
}

// ✅ Use reasonable delay (500ms for search)
Duration(milliseconds: 500)

// ❌ Don't debounce instant actions (button clicks)
// Buttons should respond immediately

// ❌ Don't use too long delay (poor UX)
// Duration(seconds: 5) ❌ Too long!

Common Use Cases

  1. Search fields — Wait for user to stop typing
  2. Autocomplete — Suggest results after delay
  3. API calls — Avoid excessive requests
  4. Form validation — Validate after user finishes input
  5. Window resize — Recalculate layout after resize stops

Summary

TechniqueTriggerExample
DebouncingAfter delay since last eventSearch input (wait 500ms after typing stops)
ThrottlingAt fixed intervalsScroll listener (every 200ms)

Key Takeaway: Debouncing improves performance by reducing unnecessary function calls (especially API calls).

Learn more: Debouncing in Flutter