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.
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
AbortControllerconfetche 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).
- Versione base: digita veloce, osserva il bug della risposta ritardata.
- Aggiungi
AbortController: verifica in DevTools Network che le richieste vengano cancellate. - Aggiungi debounce 300ms: osserva la riduzione di richieste.
- (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 catchAbortError. Diventa rejection non gestita.- Confondere debounce e throttle. Servono a cose diverse.