JavaScript: autocomplete con addEventListener, delega e Fetch

by Anastasia P.
SHARE
JavaScript: autocomplete con addEventListener, delega e Fetch
© Guida-HTML5.it

Per creare un autocomplete JavaScript efficiente, delega gli eventi input e click a un contenitore stabile, ritarda la ricerca con un debounce e usa AbortController per annullare le richieste Fetch superate. Questo pattern mantiene funzionanti i risultati inseriti dinamicamente, limita il lavoro di rete e riduce il rischio di mostrare suggerimenti obsoleti.

Sintassi rapida (TL;DR):

let timer;
let controller;

root.addEventListener('input', (event) => {
  if (!event.target.matches('[data-search]')) return;
  clearTimeout(timer);
  controller?.abort();
  timer = setTimeout(() => search(event.target.value), 250);
});

Autocomplete JavaScript: quando usare delega, debounce e AbortController

Una casella di ricerca con suggerimenti può generare una richiesta per ogni carattere digitato. Senza debounce, una parola di dieci lettere può produrre dieci chiamate ravvicinate; se la rete risponde fuori ordine, una risposta lenta può inoltre sovrascrivere risultati più recenti. Aggiungere un listener a ogni singolo suggerimento è fragile: i nodi vengono ricreati a ogni ricerca e i nuovi elementi non ereditano i listener precedenti.

La soluzione è collegare i listener una sola volta a un elemento contenitore che non viene sostituito. La delega intercetta gli eventi provenienti dagli elementi discendenti, inclusi quelli creati successivamente. Il debounce aspetta che l’utente interrompa brevemente la digitazione, mentre AbortController interrompe una richiesta non più utile. Un controllo progressivo delle richieste completa il pattern: impedisce a una risposta già arrivata o in fase di elaborazione di aggiornare risultati non più pertinenti.

Autocomplete con addEventListener e Fetch: esempio completo

Il seguente modulo crea una casella di ricerca accessibile e gestisce suggerimenti restituiti da un endpoint. L’API configurata deve rispondere a /api/search?q=termine con JSON nel formato [{"id":"42","title":"Risultato"}]. Incolla il codice in un file JavaScript caricato dopo il DOM, per esempio con uno script type="module".

const root = document.createElement('section');
root.innerHTML = `
  <label for="site-search">Cerca</label>
  <input id="site-search" data-search
    type="search" autocomplete="off"
    aria-controls="search-results" aria-expanded="false">
  <ul id="search-results" role="listbox"></ul>
  <p data-status role="status" aria-live="polite"></p>
`;
document.body.append(root);

const input = root.querySelector('[data-search]');
const results = root.querySelector('#search-results');
const status = root.querySelector('[data-status]');

let timerId;
let controller;
let requestId = 0;

function render(items) {
  results.replaceChildren();

  for (const item of items) {
    const option = document.createElement('li');
    option.setAttribute('role', 'option');
    option.dataset.id = String(item.id);
    option.textContent = String(item.title);
    results.append(option);
  }

  input.setAttribute('aria-expanded', String(items.length > 0));
}

async function search(query, currentRequest) {
  controller = new AbortController();

  try {
    const url = new URL('/api/search', window.location.origin);
    url.search = new URLSearchParams({ q: query });

    const response = await fetch(url, {
      signal: controller.signal,
      headers: { Accept: 'application/json' }
    });

    if (!response.ok) {
      throw new Error(`Errore HTTP ${response.status}`);
    }

    const items = await response.json();

    if (!Array.isArray(items)) {
      throw new TypeError('La risposta API deve essere un array');
    }

    if (currentRequest !== requestId) return;

    render(items);
    status.textContent = `${items.length} risultati`;
  } catch (error) {
    if (error.name === 'AbortError') return;
    if (currentRequest !== requestId) return;

    render([]);
    status.textContent = 'Ricerca non disponibile. Riprova.';
    console.error('Errore durante la ricerca:', error);
  }
}

root.addEventListener('input', (event) => {
  if (!event.target.matches('[data-search]')) return;

  const query = event.target.value.trim();
  clearTimeout(timerId);
  controller?.abort();
  requestId += 1;

  if (query.length < 2) {
    render([]);
    status.textContent = query ? 'Digita almeno 2 caratteri.' : '';
    return;
  }

  status.textContent = 'Ricerca in corso…';
  const currentRequest = requestId;
  timerId = setTimeout(() => search(query, currentRequest), 250);
});

root.addEventListener('click', (event) => {
  const option = event.target.closest('[role="option"]');
  if (!option || !root.contains(option)) return;

  input.value = option.textContent;
  render([]);
  status.textContent = `Selezionato: ${input.value}`;
});

Comportamento nel browser

Digitazione: "ja"
Dopo 250 ms: GET /api/search?q=ja
Risposta: [{"id":"42","title":"JavaScript"}]
Suggerimento mostrato: JavaScript
Click sul suggerimento:
Campo di ricerca: "JavaScript"
Stato accessibile: "Selezionato: JavaScript"

L’output dei suggerimenti dipende dalla risposta dell’endpoint: le righe sopra mostrano un esempio con una risposta specifica, non un risultato garantito dall’esecuzione senza un server API. Se l’endpoint restituisce un errore HTTP o JSON non valido, l’interfaccia mostra il messaggio di indisponibilità senza interrompere il resto della pagina.

Come funzionano delega degli eventi, richieste Fetch e rendering sicuro

1. Delega degli eventi con addEventListener

I listener sono registrati su root, non su ogni risultato. L’evento input risale dal campo al contenitore; il controllo con matches() ignora gli altri elementi. Per il click, closest() trova l’opzione anche quando l’utente clicca su un suo discendente. Il controllo root.contains() evita di agire su elementi esterni al componente.

2. Debounce, annullamento e risposte fuori ordine

clearTimeout() rimuove la ricerca programmata prima di creare quella successiva. Quando l’utente cambia testo, controller?.abort() annulla la richiesta in corso; l’errore AbortError viene ignorato perché è una conseguenza prevista. Il contatore requestId protegge anche dal caso in cui una risposta o la sua decodifica siano già arrivate: solo la richiesta con identificatore corrente può aggiornare la UI.

3. Validazione dei dati e prevenzione XSS

Il codice controlla response.ok perché Fetch non genera automaticamente un’eccezione per risposte HTTP come 404 o 500. Verifica inoltre che il JSON sia un array. I titoli dei risultati vengono assegnati tramite textContent, non inseriti con innerHTML: in questo modo una stringa controllata dal server non viene interpretata come markup eseguibile. Per un’API pubblica, aggiungi anche limiti lato server, autorizzazione e validazione dei parametri.

Confronto tra listener diretti, delega e gestione delle richieste

La scelta dipende da quanto spesso cambiano i nodi e da quanto lavoro comporta ogni interazione. Il debounce e l’annullamento non sostituiscono la delega: risolvono problemi distinti e si combinano nello stesso componente.

Metodo / Approccio Sintassi / Caratteristica Performance / Complessità Quando sceglierlo
Listener diretto element.addEventListener() Un listener per elemento; va ricollegato ai nodi ricreati. Elementi pochi e stabili.
Delega degli eventi container.addEventListener() Un listener per tipo di evento sul contenitore. Risultati dinamici o molti elementi interattivi.
Debounce e AbortController setTimeout() e controller.abort() Riduce richieste superflue; richiede gestione del ciclo di vita. Autocomplete e ricerca remota durante la digitazione.

Errori comuni con addEventListener, Fetch e autocomplete

  • Risultati obsoleti: abortire una richiesta aiuta, ma non sostituisce il controllo requestId. Verifica che la risposta appartenga ancora all’ultima ricerca prima di aggiornare il DOM.
  • Listener duplicati: registrare nuovi handler a ogni render può causare click ripetuti e trattenere riferimenti inutili. Collega gli eventi una sola volta al contenitore stabile.
  • Uso di innerHTML con dati API: può introdurre una vulnerabilità XSS. Crea gli elementi DOM e usa textContent per le stringhe non fidate.
  • Errori HTTP non gestiti: Fetch risolve la Promise anche per status HTTP di errore. Controlla response.ok e gestisci separatamente l’annullamento previsto.

FAQ su delega degli eventi e ricerca Fetch in JavaScript

Perché usare la delega invece di addEventListener su ogni risultato?

La delega funziona anche quando i risultati vengono ricreati dopo una risposta Fetch: il listener sul contenitore rimane attivo. Riduce il numero di listener e semplifica il cleanup, soprattutto per liste di dimensione variabile.

AbortController impedisce sempre che appaiano risultati vecchi?

No: interrompe operazioni ancora annullabili, ma una risposta può essere già arrivata o in fase di elaborazione. Un identificatore progressivo della richiesta, confrontato prima del rendering, protegge anche questi casi.

Come gestire AbortError senza mostrare un messaggio di errore?

Controlla error.name === 'AbortError' nel blocco catch e termina la gestione senza modificare lo stato dell’interfaccia. L’annullamento causato da una nuova digitazione è normale e non va presentato come un guasto all’utente.

Documentazione ufficiale su addEventListener e Fetch

SHARE