Recipe: Search with debounce + AbortController
Type-to-search input with a 300 ms debounce. Each new request aborts the previous one via AbortController so stale responses can't overwrite a fresher list. Four states — idle / loading / empty / done — driven by one method.
Live preview
Start typing to search.
Searching…
No countries match ""
Full source
<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>
Micra.define('country-search', {
state: { query: '', results: [], status: 'idle' },
onCreate() {
// Optional: pre-warm a cache or fire an initial empty search here.
},
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 — 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 you'd have no place to start the debounce timer on each keystroke. Using @input + manual value:query binding gives you both the timer hook AND keeps the input value driven by state — so programmatic this.state.query = 'x' still updates the input.
Pitfalls
- Don't skip the AbortController. Without it, a slow first request can land after a fast second one and clobber the visible list with stale matches. The "1 in 50" bug.
- Don't debounce inside data-model's input event. Micra runs the data-model handler synchronously to keep state in sync. Add your own @input alongside if you really need both — but the bind-only pattern above is cleaner.
- Don't forget to clear the empty case. Clearing the input must abort the in-flight request AND clear results — otherwise old matches linger when the user wipes their query.
- Don't render results alongside the "empty" message. Each state branch is mutually exclusive — gate on status, not on results.length alone.
- Always clean up in onDestroy. clearTimeout + abort(). Otherwise a pending request fires after teardown and writes to a state that no longer drives any DOM.