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.

debounce 300ms AbortController loading / empty / error

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.