modulo 07 / lezione 37

Debugging asincrono e interfacce dinamiche

Race condition e AbortController

Riconoscere e prevenire race condition (doppio submit, stale closure, setState dopo unmount). Usare AbortController per cancellare fetch e listener.

55 min Debugging

In parole semplici

Una race condition è quando due eventi competono e l’ordine non è deterministico. In UI tipica:

  • L’utente digita “Ca”, parte fetch.
  • L’utente digita “Carlo”, parte fetch.
  • La risposta di “Ca” arriva dopo “Carlo” e sovrascrive.

L’utente vede risultati per “Ca” mentre legge “Carlo”. Race condition.

AbortController (standard moderno) è lo strumento elegante per cancellare richieste e listener obsoleti.

Parole nuove

  • race condition: due eventi competono, l’ordine non garantisce coerenza.
  • stale closure: closure che cattura un valore vecchio.
  • AbortController / AbortSignal: meccanismo per cancellare operazioni asincrone.
  • debounce / throttle: tecniche per limitare frequenza di chiamate.

Cosa impari

  • Identificare race condition tipiche in UI.
  • Usare AbortController con fetch e listener.
  • Riconoscere e curare le stale closure.

Race classica: ricerca a digitazione

Versione buggata:

async function cerca(query) {
  const r = await fetch(`/api/cerca?q=${query}`);
  const data = await r.json();
  setRisultati(data);
}

input.addEventListener("input", e => cerca(e.target.value));

Bug: il primo fetch (per “Ca”) può arrivare dopo il terzo (per “Carlo”). setRisultati viene chiamato con dati obsoleti.

Fix 1: ignorare risposte obsolete

let ultimoToken = 0;
async function cerca(query) {
  const mioToken = ++ultimoToken;
  const r = await fetch(`/api/cerca?q=${query}`);
  if (mioToken !== ultimoToken) return;  // arrivata in ritardo
  const data = await r.json();
  setRisultati(data);
}

Funziona, ma le richieste obsolete continuano a viaggiare in rete. Spreco.

Fix 2: AbortController (modo idiomatico)

let controller;
async function cerca(query) {
  if (controller) controller.abort();
  controller = new AbortController();
  try {
    const r = await fetch(`/api/cerca?q=${query}`, { signal: controller.signal });
    const data = await r.json();
    setRisultati(data);
  } catch (err) {
    if (err.name !== "AbortError") throw err;
  }
}

Le richieste obsolete vengono cancellate, non solo ignorate.

AbortController con event listener

const controller = new AbortController();
button.addEventListener("click", handler, { signal: controller.signal });

// più tardi: rimuovi tutti i listener associati al controller
controller.abort();

Un solo controller.abort() rimuove tutti i listener registrati con quel signal. Pulizia centralizzata.

Utile per componenti UI: registri al mount, aborti all’unmount.

Race in React: stale closure

function Contatore() {
  const [count, setCount] = useState(0);

  useEffect(() => {
    const id = setInterval(() => {
      setCount(count + 1);  // bug: count è "catturato" al primo render = 0
    }, 1000);
    return () => clearInterval(id);
  }, []);  // dipendenze vuote

  return <div>{count}</div>;
}

Bug: count è la closure del primo render. Sempre 0. Vedi 1 al secondo, 1 al secondo, 1 al secondo.

Fix con functional updater:

setCount(prev => prev + 1);  // niente closure su count

Oppure aggiungi count alle dipendenze (ma allora l’interval si ricrea ogni secondo, spesso non desiderato).

Race: setState dopo unmount

function Dettaglio({ id }) {
  const [dato, setDato] = useState(null);
  useEffect(() => {
    fetch(`/api/${id}`).then(r => r.json()).then(setDato);
  }, [id]);
  ...
}

Se il componente si smonta prima che il fetch torni, setDato è chiamato su componente smontato. React lancia warning.

Fix con AbortController:

useEffect(() => {
  const controller = new AbortController();
  fetch(`/api/${id}`, { signal: controller.signal })
    .then(r => r.json())
    .then(setDato)
    .catch(err => { if (err.name !== "AbortError") throw err; });
  return () => controller.abort();
}, [id]);

Debounce e throttle

Quando una sorgente di eventi è troppo veloce (digitazione, scroll, resize), riduci la frequenza:

function debounce(fn, ms) {
  let timer;
  return (...args) => {
    clearTimeout(timer);
    timer = setTimeout(() => fn(...args), ms);
  };
}

const cercaDebounced = debounce(cerca, 300);
input.addEventListener("input", e => cercaDebounced(e.target.value));

debounce aspetta che la sorgente si fermi ms millisecondi prima di chiamare. throttle limita a una chiamata ogni ms indipendentemente.

Spesso debounce + AbortController insieme: meno chiamate e cancellazione di quelle obsolete.

Doppio submit

button.addEventListener("click", async () => {
  await salva();
});

Bug: due click veloci → due salva() in parallelo.

Fix: stato “in volo”:

let inCorso = false;
button.addEventListener("click", async () => {
  if (inCorso) return;
  inCorso = true;
  try { await salva(); }
  finally { inCorso = false; }
});

Oppure disabilita il pulsante:

button.disabled = true;
try { await salva(); }
finally { button.disabled = false; }

Esercizio guidato

Costruisci una piccola ricerca con fetch simulato (setTimeout con risposta a tempo variabile).

  1. Versione base: digita veloce, osserva il bug della risposta ritardata.
  2. Aggiungi AbortController: verifica in DevTools Network che le richieste vengano cancellate.
  3. Aggiungi debounce 300ms: osserva la riduzione di richieste.
  4. (Bonus) versione React con useEffect + cleanup: confronta i due approcci.

Errori frequenti del principiante

  • Pensare che JavaScript single-threaded eviti race. Single-threaded sì, ma con I/O asincrono.
  • Ignorare le stale closure. Bug ricorrente in hook React.
  • controller.abort() senza catch AbortError. Diventa rejection non gestita.
  • Confondere debounce e throttle. Servono a cose diverse.