Errori e fix

useEffect viene eseguito due volte: è StrictMode, non un bug

In sviluppo React monta, smonta e rimonta ogni componente per esporre effetti senza pulizia. La correzione è scrivere la cleanup, non disattivare StrictMode.

In sintesi

  • Il doppio avvio esiste solo in sviluppo e solo con ``: serve a trovare effetti non idempotenti.
  • Un effetto corretto annulla la richiesta o la sottoscrizione nella cleanup.
  • Se il doppio avvio rompe la funzione, il problema si manifesterebbe anche in produzione su un rimontaggio reale.

Risposta breve: non è un bug. Con <StrictMode> React 18 e 19 eseguono ogni effetto due volte in sviluppo, mount, cleanup, mount, per farti vedere gli effetti che non sanno annullarsi.

Il meccanismo: un test, non una doppia esecuzione

StrictMode chiede a React di provare il ciclo completo: monta, esegue l'effetto, esegue la cleanup, rimonta ed esegue di nuovo. L'obiettivo è dimostrare che l'effetto sa tornare indietro. Un effetto che sopravvive al ciclo è un effetto che si comporterà bene quando il componente verrà davvero rimontato.

Il comportamento esiste solo in sviluppo e solo con il wrapper attivo. In produzione il ciclo è uno. Attenzione a un dettaglio: anche il Fast Refresh rimonta i componenti mentre lavori, quindi due richieste possono arrivare da un salvataggio e non da StrictMode.

mount → effect → cleanup → effect

# in produzione
mount → effect

Il doppio passaggio non è una penalità di prestazioni: è un test. Un effetto che non supporta il ciclo è un effetto che si romperà su un rimontaggio reale, cioè su un cambio di rotta o su una chiave diversa.

La correzione: cleanup simmetrica

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

Senza abort la prima risposta può arrivare dopo la seconda e sovrascrivere lo stato con dati vecchi. È il classico difetto che in sviluppo sembra un doppio fetch e in produzione diventa un dato sbagliato, difficile da riprodurre perché dipende dall'ordine di arrivo delle risposte.

Le sottoscrizioni

useEffect(() => {
  const mq = window.matchMedia("(min-width: 768px)");
  const onChange = () => setWide(mq.matches);
  onChange();
  mq.addEventListener("change", onChange);
  return () => mq.removeEventListener("change", onChange);
}, []);

La regola è generale: ogni addEventListener, setInterval, subscribe o observe deve avere la sua rimozione. Se la cleanup è vuota, il doppio avvio sta facendo il suo lavoro e ti sta mostrando un leak.

Diagnosi: conta le coppie, non le esecuzioni

Un contatore con useRef e due log dicono subito se il ciclo è sano. Il numero assoluto conta poco: conta la sequenza.

const runs = useRef(0);

useEffect(() => {
  runs.current += 1;
  console.log(`effect #${runs.current}`, userId);
  return () => console.log(`cleanup #${runs.current}`);
}, [userId]);

Due effetti con una cleanup in mezzo sono il segnale atteso. Dieci effetti in fila indicano una dipendenza che cambia identità a ogni render. Un solo effetto senza cleanup significa che il componente non si è mai smontato, e il problema è altrove.

Quando la correzione comune peggiora le cose

La prima scorciatoia è passare a dipendenze vuote per fermare il ciclo. L'effetto smette di ripartire e continua a leggere i valori del primo render: chiedi i dati di un utente e ricevi quelli del precedente. L'errore si sposta da «due richieste» a «dati di un altro utente», che in produzione è molto peggio.

La seconda è una guardia con un flag a livello di modulo, del tipo «se ho già girato, esci». Funziona finché il componente non viene rimontato: la variabile sopravvive al rimontaggio, l'effetto non riparte e la sottoscrizione resta chiusa per sempre. Una guardia con useRef è meno dannosa, ma nella stessa StrictMode salta la seconda esecuzione dopo che la cleanup ha già chiuso tutto.

Un limite da mettere in conto: il test ha effetti collaterali

Il ciclo di prova esegue davvero il codice, due volte. Se l'effetto invia un POST, in sviluppo il server riceve due scritture. StrictMode non può sapere se l'operazione è idempotente: la doppia esecuzione è un test distruttivo per definizione. La conseguenza pratica è che l'effetto deve essere sicuro da ripetere, oppure l'invio va spostato su un gestore di evento con una chiave di idempotenza.

Come prevenirlo

  • Scrivi la cleanup insieme all'effetto, prima di testare.
  • Usa AbortController per ogni richiesta legata al ciclo di vita di un componente.
  • Non disattivare StrictMode per far passare un test: correggi l'effetto.
  • Regressione: monta, smonta e rimonta il componente nel test e verifica che non restino listener.

Dove continuare

Nel catalogo: componenti React, dashboard con dati e icone. Le guide: checklist accessibilità e peso di una libreria UI; il confronto: copy-paste o headless.

Domande frequenti

Il doppio avvio invia due richieste al server?

Sì, in sviluppo. Per questo la cleanup deve annullare la prima con `AbortController`: senza, la prima risposta può sovrascrivere la seconda con dati vecchi.

Posso togliere StrictMode?

Puoi, ma perdi il controllo che React offre proprio per trovare effetti fragili. Togli il wrapper e il leak resta, con la differenza che non hai più un ambiente che lo mostra.

Succede anche in produzione?

Non per StrictMode. Succede quando il componente viene rimontato davvero: cambio rotta, chiave diversa, sospensione, oppure un Fast Refresh durante lo sviluppo.

Vale anche per `useLayoutEffect`?

Sì, la regola è identica: ogni sottoscrizione o richiesta deve avere una cleanup simmetrica. Cambia il momento in cui il ciclo viene eseguito, non il contratto.

Come verifico che la cleanup funzioni?

Aggiungi un log nella cleanup e monta il componente due volte: deve comparire prima del secondo effetto, senza avvisi di stato aggiornato dopo lo smontaggio.