Recipe: Search with debounce + AbortController
A type-to-search input that waits 300 ms after the last keystroke, then fetches.
Each new request aborts the previous one via AbortController, so a slow
earlier response can’t land after a faster later one and clobber the list. Four
states — idle / loading / empty / done — are driven by a single
status field.
Markup
<div data-component="country-search">
<input @input="onInput" data-bind="value:query" placeholder="Search…" />
<p data-if="status === 'idle'">Start typing.</p>
<p data-if="status === 'loading'">Searching…</p>
<p data-if="status === 'empty'">No matches.</p>
<div data-if="status === 'done'">
<template data-each="results" data-key="code">
<div data-text="item.name"></div>
</template>
</div>
</div>
Component
Micra.define("country-search", {
state: { query: "", results: [], status: "idle" },
onDestroy() {
clearTimeout(this._timer);
this._controller?.abort();
},
onInput(e) {
this.state.query = e.target.value;
clearTimeout(this._timer);
if (!this.state.query.trim()) {
this._controller?.abort();
this.state.results = [];
this.state.status = "idle";
return;
}
this.state.status = "loading";
this._timer = setTimeout(() => this._search(), 300);
},
async _search() {
// Abort the previous in-flight request so its (stale) response
// can't overwrite results from a fresher query.
this._controller?.abort();
this._controller = new AbortController();
try {
const rows = await this.fetch(
"/api/countries?q=" + encodeURIComponent(this.state.query),
{ signal: this._controller.signal },
);
this.state.results = rows;
this.state.status = rows.length ? "done" : "empty";
} catch (e) {
if (e.name === "AbortError") return; // expected — a newer query took over
this.state.status = "idle"; // network / 500 — fall back to idle
}
},
});
Why @input instead of data-model?
data-model would two-way bind the input, but it leaves you no hook to start
the debounce timer on each keystroke. @input plus a one-way data-bind="value:query"
gives you both the timer hook and a state-driven value — so a programmatic
this.state.query = "x" still updates the field.
Pitfalls
- Don’t skip the
AbortController. Without it, a slow first request can land after a fast second one and overwrite the visible list with stale matches. - Clear the empty case. Wiping the input must abort the in-flight request
and clear
results, or old matches linger. - Gate on
status, notresults.length. The four states are mutually exclusive — branch onstatus. - Always clean up in
onDestroy(clearTimeout+abort()), or a pending request resolves after teardown and writes to state nothing renders anymore.