Search as you type
A search field over the product list. Typing "kettle" should not send six
requests, an empty field should send none, and a request for "ket" that is
still on its way when the user has typed "kett" should be stopped rather than
answered and thrown away. While the new results load, the old ones should stay
on screen — dimmed, not replaced by a spinner that flickers on every
keystroke. Four pieces do it: a debounce in the widget, a disabled query for
the empty field, the cancellation signal, and keepPrevious placeholder data.
The finished code
The options: the list query, per search term, off while the term is empty.
QueryObserverOptions<List<Product>> productSearchQuery(
ProductApi api,
String needle,
) =>
productListQuery(api, search: needle).copyWith(
// An empty box asks the server nothing.
enabled: needle.isEmpty ? Enabled.no : Enabled.yes,
// While the new needle loads, keep showing the last results.
placeholderData: const PlaceholderData<List<Product>>.keepPrevious(),
);
The screen, read through the QueryMixin methods:
class ProductSearchScreen extends StatefulWidget {
const ProductSearchScreen({super.key});
State<ProductSearchScreen> createState() => _ProductSearchScreenState();
}
class _ProductSearchScreenState extends State<ProductSearchScreen>
with QueryMixin {
static const Duration debounce = Duration(milliseconds: 300);
Timer? _debounce;
String _needle = '';
void _onChanged(String text) {
_debounce?.cancel();
_debounce = Timer(debounce, () {
final needle = text.trim();
if (mounted && needle != _needle) setState(() => _needle = needle);
});
}
void dispose() {
_debounce?.cancel();
super.dispose();
}
Widget build(BuildContext context) {
// One `id`, so the read follows the needle from key to key and
// `keepPrevious` has a previous to keep.
final results = watchQuery(
productSearchQuery(ProductApiScope.of(context), _needle),
id: 'search',
);
return Scaffold(
appBar: AppBar(
title: TextField(
autofocus: true,
onChanged: _onChanged,
decoration: const InputDecoration(hintText: 'Search products'),
),
bottom: results.isFetching
? const PreferredSize(
preferredSize: Size.fromHeight(4),
child: LinearProgressIndicator(),
)
: null,
),
body: _needle.isEmpty
? const Center(child: Text('Type to search'))
: switch (results) {
QueryPending() => const SizedBox.shrink(),
QueryError(:final error) => Center(child: Text('$error')),
QuerySuccess(data: []) =>
Center(child: Text('Nothing matches "$_needle"')),
QuerySuccess(:final data) => Opacity(
// Last needle's results, while this one loads.
opacity: results.isPlaceholderData ? 0.5 : 1,
child: ListView(
children: <Widget>[
for (final product in data) ProductTile(product),
],
),
),
},
);
}
}
productListQuery is the list query from
List to detail, seeded; its key is
ProductKeys.list(search: needle), so every term has an entry of its own.
How it works
- The debounce lives in the widget. The text field changes on every
keystroke;
_needlechanges only after 300 ms without one. The query reads_needle, so the key — and with it the request — changes once per pause in the typing, not once per letter. - A new term is a new key.
ProductKeys.list(search: 'kett')andProductKeys.list(search: 'ket')are different entries. Going back to a term searched a moment ago shows its cached results at once, fresh for the list query's thirty-secondstaleTime. - An empty field is a disabled query.
Enabled.nomeans the query never fetches on its own; the screen shows "Type to search" instead of reading the result. Clearing the box costs nothing. - The superseded request is cancelled. When the key changes, the reader
moves on to the new entry and the old one has no reader left. Its query
function consumed
context.signal(the API client passes it to dio), so the library cancels the fetch and the bridge from Wiring dio or package:http aborts the request on the wire. keepPreviousholds the last results. While the new term's first fetch runs, the result is aQuerySuccesswith the previous term's data andisPlaceholderDataset. The list stays, at half opacity, and the progress bar in the app bar says something is on its way.- One
idfor the read.watchQuery(..., id: 'search')tells the mixin that the read for "ket" and the read for "kett" are the same read with a new key. Without it, a changed key would be a new read, and a new read has no previous data to keep.
Try it in the demo's "Search as you type" card: type a few letters, pause,
then type again before the answer arrives — searchCancels counts the
searches stopped in flight. The demo's backend is in memory, with a deliberate
delay.
Traps
- Debouncing in the query function does not work. A
Future.delayedbefore the request makes every keystroke a fetch that waits, then runs; the entries still pile up, one per letter. Debounce the key. - A query function that ignores the signal is not cancelled. When the
key moves on, the library cancels the old fetch only if its function read
context.signal. One that never read it is left to finish: the request runs to the end and its answer is cached under the old term. Passcontext.signalto the transport. - Trim before comparing.
'ket 'and'ket'are two keys and two requests for the same results; the screen trims the term once, where it sets_needle. - Placeholder data is not cached data. While
isPlaceholderDatais true, the rows belong to the previous term. Do not act on them as if they answered the current one — a "3 results for kett" header would be wrong for a moment. This screen dims them instead. - The list query seeds the detail entries. Every search response writes each product to its detail entry, which is what makes a tapped result open at once. That is useful here, but it means a search returning 200 products writes 200 entries; drop the seeding for a search that returns large pages.
Variations
- Search on submit. Drop the debounce and set
_needlein the field'sonSubmitted. The rest stays. - A minimum length.
enabled: needle.length < 2 ? Enabled.no : Enabled.yes— and the matching message in the empty state. - Another call style. The same options work with
context.query— the id there is a named argument too — with aQueryBuilder, whose observer already follows a changing key, or with aQueryControllerin a view model that owns the debounce; callsetOptionson it with the new term.
The same recipe, with placeholderData: keepPreviousData and a
useDebounce hook. A hook's identity comes from its position in the
component, so a changed key keeps the observer without asking; here the mixin
is told with id:.
See also
- Disabling queries — lazy
queries and
Enabled. - Query cancellation — what a cancel does to the entry, and what the signal adds.
- Placeholder query data
—
keepPreviousandisPlaceholderData. - Reading queries in widgets — the
four call styles and what
id:does.