Small details that build taste in Flutter.

curated by Kamran BekirovKamran Bekirov

Debounce the search field (done properly)

Don't send a search request on every typed letter. When the user types "phone", that's 5 requests, and only the last one matters. Wait until the user stops typing, then search once.

A simple approach is using rxdart's operators, or implement it however you want:

import 'dart:async';
  
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:rxdart/rxdart.dart';
  
const Duration _delay = Duration(milliseconds: 400);
const int _minLength = 2;
  
class SearchState {
  final String query;
  final List<SearchResult> results;
  final bool loading;
  
  const SearchState({
    this.query = '',
    this.results = const [],
    this.loading = false,
  });
}
  
class SearchCubit extends Cubit<SearchState> {
  SearchCubit() : super(const SearchState()) {
    _subscription = _queries
        // "ab " and "ab" are the same query
        .map((String text) {
          return text.trim();
        })
        
        // wait until the user stops typing
        .debounce((query) {
          if (query.length < _minLength) {
            return Stream<void>.value(null);
          }
  
          return TimerStream<void>(null, _delay);
        })
          
        // skip if it's the same as the last searched query
        .distinct()
          
        // keep only the latest search alive, cancel the old one
        .switchMap((String query) async* {
          if (query.length < _minLength) {
            yield const SearchState();
            return;
          }
  
          yield SearchState(query: query, loading: true);
  
          final List<SearchResult> results = await ApiService.fetchResults(
            query,
          );
  
          yield SearchState(
            query: query, 
            results: results,
          );
        })
        // every yield above lands here and becomes the new state
        .listen(
          emit,
          // a failed request turns loading off, keeps the last results
          onError: (_) {
            emit(
              SearchState(
                query: state.query, 
                results: state.results,
              ),
            );
          },
        );
  }
  
  final PublishSubject<String> _queries = PublishSubject<String>();
  
  late final StreamSubscription<SearchState> _subscription;
  
  void search(String text) {
    _queries.add(text);
  }
  
  @override
  Future<void> close() async {
    await _subscription.cancel();
    await _queries.close();
  
    return super.close();
  }
}

The UI side is simple. The text field calls cubit.search(text) in onChanged. A BlocBuilder watches the state: it shows the results, a shimmer/loading indicator while loading is true, and a "no results" view when the list comes back empty.

Two cases people miss:

  • An old answer must not replace a new one. The user types "ab", a request starts. Before it finishes they type "c", a new request starts for "abc". If the "ab" answer arrives last, the screen would show results for "ab" while the field says "abc". switchMap prevents this: when a new search starts, the old one is thrown away, so its answer never reaches the screen.
  • The same search must not run twice. The user types "abc", then deletes the "c" during the wait. The text is "ab" again, and "ab" was already searched. distinct sees it's the same query and does nothing.

One note: a thrown away request still finishes in the background, only its answer is ignored. That's fine. If you want to stop the request itself too, cancel it in your HTTP client, in Dio that's a CancelToken.

Kamran Bekirov
Kamran Bekirov

Want this level of care in your Flutter apps?

Work With Me