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