Debounced search field
Search box that waits for a pause in typing before querying and ignores out-of-order responses from slower requests.
FlutterSearchfluttersearchdebouncetextfield
Code
import 'dart:async';
import 'package:flutter/material.dart';
typedef SearchFn<T> = Future<List<T>> Function(String query);
class DebouncedSearch<T> extends StatefulWidget {
const DebouncedSearch({
super.key,
required this.search,
required this.resultBuilder,
this.delay = const Duration(milliseconds: 350),
this.minLength = 2,
});
final SearchFn<T> search;
final Widget Function(BuildContext, T) resultBuilder;
final Duration delay;
final int minLength;
@override
State<DebouncedSearch<T>> createState() => _DebouncedSearchState<T>();
}
class _DebouncedSearchState<T> extends State<DebouncedSearch<T>> {
final _controller = TextEditingController();
Timer? _timer;
int _ticket = 0; // increases for each request; only the newest may apply
List<T> _results = [];
bool _busy = false;
@override
void dispose() {
_timer?.cancel();
_controller.dispose();
super.dispose();
}
void _onChanged(String raw) {
_timer?.cancel();
final q = raw.trim();
if (q.length < widget.minLength) {
_ticket++; // invalidate anything still in flight
setState(() { _results = []; _busy = false; });
return;
}
_timer = Timer(widget.delay, () => _run(q));
}
Future<void> _run(String q) async {
final mine = ++_ticket;
setState(() => _busy = true);
try {
final rows = await widget.search(q);
if (mounted && mine == _ticket) setState(() => _results = rows);
} finally {
if (mounted && mine == _ticket) setState(() => _busy = false);
}
}
@override
Widget build(BuildContext context) {
return Column(
children: [
Padding(
padding: const EdgeInsets.all(12),
child: SearchBar(
controller: _controller,
hintText: 'Search',
leading: const Icon(Icons.search),
trailing: [if (_busy) const SizedBox(width: 18, height: 18, child: CircularProgressIndicator(strokeWidth: 2))],
onChanged: _onChanged,
),
),
Expanded(
child: ListView(children: [for (final r in _results) widget.resultBuilder(context, r)]),
),
],
);
}
}
How it works
What it solves
Firing a request on every keystroke wastes bandwidth and produces flicker. Worse, a slow response to an old query can arrive after a newer one and overwrite the correct results.
How it works
Each change cancels the pending Timer and starts a new one, so the query only runs after the user pauses for delay. Queries shorter than minLength clear the list instead of hitting the network.
A ticket counter solves the ordering problem. _run takes the next ticket number before awaiting, and only the response that still holds the newest ticket may update the state or the busy flag. Clearing the field also bumps the ticket, which discards requests still on the wire.
Adapting it
Tune delay between 250 and 500 ms depending on API latency. Replace SearchBar with a TextField if you need a custom decoration, and add a recent-searches list when the field is empty. For streams, the same effect is available with a StreamController and a debounceTime transformer from rxdart.
Pitfalls
- Cancelling the timer does not cancel an HTTP request; the ticket just ignores it. Use a cancel token if the server cost matters.
- Do not call
setStateafter dispose, hence themountedchecks. - Trim input so trailing spaces do not trigger duplicate queries.
How to use it
Copy the file to lib/widgets/. No packages.
DebouncedSearch<Item>(
search: (q) => api.searchItems(q),
resultBuilder: (context, item) => ListTile(title: Text(item.title)),
)
Licence: DEV3 Snippet Licence. Free to use, change and ship in personal and commercial projects. Attribution is appreciated, not required. Please do not redistribute snippets as a collection.