Aggiorni la pagina e lo schermo rimane bianco. O forse il clic su un pulsante fa scattare la ventola della CPU al massimo. Poi la console sputa fuori l'oscuro avviso: Maximum update depth exceeded. React ha tirato il freno a mano perché il tuo componente è intrappolato in un loop infinito. È uno degli errori più comuni nelle applicazioni React e solitamente deriva da un semplice malinteso su quando il codice viene effettivamente eseguito.

Per risolverlo, devi capire l'istante esatto in cui un render diventa un re-render e perché i cambiamenti di stato devono rimanere fuori dal percorso di rendering.

Come React renderizza il tuo componente

React costruisce interfacce utente partendo da componenti. Nel React moderno, questi componenti sono funzioni. Ogni volta che React ha bisogno di mostrare il tuo componente sullo schermo, chiama semplicemente quella funzione. All'interno della funzione, puoi usare lo state per ricordare le cose tra un render e l'altro. Lo stato dice a React quali dati appartengono al componente e, cosa fondamentale, quando qualcosa è cambiato e l'interfaccia utente deve essere aggiornata.

Quando lo stato cambia, React pianifica un nuovo render. La funzione del componente viene eseguita di nuovo, restituisce un nuovo JSX e React aggiorna il DOM di conseguenza. In un uso normale, questo ciclo è innocuo. Clicchi su un pulsante, un event handler aggiorna lo stato, React esegue un re-render e l'utente vede il nuovo testo o colore.

L'errore appare quando un render stesso innesca un altro aggiornamento dello stato. Quel nuovo aggiornamento dello stato innesca un altro render, che a sua volta innesca un altro aggiornamento dello stato. React tollera questo comportamento per qualche decina di cicli, poi lancia l'errore di profondità massima per proteggere il browser dal blocco totale.

Un rapido sguardo allo stato

Prima di analizzare il loop, ricorda come funziona l'hook useState. Ti fornisce esattamente due cose: una variabile che contiene il valore corrente e una funzione per modificare quel valore.

const MessageComponent = () => {
  const [message, setMessage] = useState('Welcome');

  return <h1>{message}</h1>;
};

Qui, message è 'Welcome' al primo render. Se in seguito chiami setMessage('Goodbye'), React nota il cambiamento, chiama nuovamente MessageComponent e l'interfaccia utente ora mostra "Goodbye". Tutto funziona correttamente perché nulla all'interno del corpo del componente chiama il setter automaticamente. Il loop inizia quando il setter viene attivato durante la fase di render senza un evento esterno.

Chiamare SetState direttamente nel corpo del componente

Il modo più diretto per creare un loop infinito è chiamare una funzione setter dello stato direttamente all'interno del corpo del componente. Poiché il corpo del componente viene eseguito a ogni render, il setter viene attivato a ogni render. Quel nuovo aggiornamento dello stato causa un altro render. Il ciclo continua all'infinito.

Ecco come appare l'errore:

const Counter = () => {
  const [count, setCount] = useState(0);

  setCount(count + 1);

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

Ogni volta che Counter viene renderizzato, incrementa count. React esegue un nuovo render per mostrare il nuovo numero, vede di nuovo setCount(count + 1) e incrementa ancora una volta. La soluzione è semplice: non chiamare mai un setter dello stato al livello principale del tuo componente durante il render. Gli aggiornamenti dello stato dovrebbero rispondere a eventi dell'utente o effetti collaterali, non all'atto di disegnare lo schermo. Sposta quell'aggiornamento in un event handler:

const Counter = () => {
  const [count, setCount] = useState(0);

  return <button onClick={() => setCount(count + 1)}>{count}</button>;
};

L'unica eccezione alla chiamata dei setter durante il render è quando stai calcolando un nuovo stato a partire dalle props, e anche in quel caso dovresti usare un pattern diverso, come derivare il valore direttamente o usare useEffect intenzionalmente.

Passare una chiamata di funzione invece di un riferimento

Un'altra causa frequente è un sottile errore di battitura nel JSX. Quando colleghi un event handler, devi passare la funzione stessa. Se invochi accidentalmente la funzione proprio lì nel JSX, questa viene eseguita immediatamente durante il ciclo di render.

const Toggle = () => {
  const [isOn, setIsOn] = useState(false);

  const handleToggle = () => setIsOn(!isOn);

  return <button onClick={handleToggle()}>Toggle</button>;
};

Scrivendo handleToggle() con le parentesi, non stai dando a React una funzione da chiamare in seguito quando l'utente clicca. La stai chiamando proprio ora, mentre React sta costruendo il virtual DOM. Poiché handleToggle aggiorna lo stato, il componente viene renderizzato di nuovo. Durante quel re-render, React vede di nuovo handleToggle() e la chiama ancora. Il loop non finisce mai. La versione corretta rimuove le parentesi:

return <button onClick={handleToggle}>Toggle</button>;

Se hai bisogno di passare degli argomenti, racchiudi la chiamata in una funzione anonima:

return <button onClick={() => handleToggle(true)}>Switch On</button>;

Questa distinzione mette in difficoltà anche gli sviluppatori esperti durante il refactoring. Fai attenzione a quelle parentesi.

La trappola delle dipendenze di useEffect

Gli "effects" sono il posto giusto per gli effetti collaterali come il recupero di dati, la sincronizzazione con le API del browser o la manipolazione manuale del DOM. Ma useEffect viene eseguito dopo che React ha applicato il render sullo schermo. Se il tuo effetto aggiorna lo stato, React eseguirà un re-render. Normalmente non c'è problema. Diventa un loop quando l'effetto viene eseguito dopo ogni render e aggiorna sempre lo stesso stato.

Considera questo pattern errato:

const UserProfile = () => {
  const [user, setUser] = useState({});

  useEffect(() => {
    setUser({ name: 'Ada', role: 'Admin' });
  });

  return <div>{user.name}</div>;
};

Poiché non è presente un array di dipendenze, questo effetto viene eseguito dopo ogni singolo render. Imposta user, il che innesca un render. Dopo quel render, l'effetto viene eseguito di nuovo e imposta nuovamente user. React rileva la spirale e lancia l'errore.

La soluzione consiste nel comunicare a React quando l'effetto deve effettivamente essere eseguito, fornendo un array di dipendenze appropriato. Se l'effetto deve essere eseguito una sola volta al montaggio, passa un array vuoto:

useEffect(() => {
  setUser({ name: 'Ada', role: 'Admin' });
}, []);

Se l'effetto dipende da una prop o da un pezzo di stato, includi solo quella variabile nell'array. Fai attenzione, però. Includere una variabile che cambia ad ogni render creerà semplicemente lo stesso loop attraverso una porta diversa. Ad esempio, se includi un oggetto letterale nelle dipendenze e quell'oggetto viene ricreato ad ogni render del componente genitore, l'effetto verrà attivato all'infinito. In questi casi, potresti dover spostare la creazione dell'oggetto all'esterno del componente o memorizzarlo (memoize).

Passaggi pratici per il debugging

Quando ti imbatti in questo errore, lo stack trace può sembrare travolgente perché React ha già ripetuto il ciclo decine di volte. Inizia leggendo la parte superiore dello stack trace per trovare quale componente viene nominato ripetutamente. Poi cerca i setter dello stato in questi tre punti:

  1. Il corpo principale del componente, al di fuori di qualsiasi handler o hook.
  2. Attributi degli eventi JSX in cui potresti aver scritto handler() invece di handler.
  3. Hook useEffect che mancano di un array di dipendenze o che dipendono da riferimenti instabili.

Commenta temporaneamente ogni setter dello stato finché l'errore non smette. Questo ti dirà esattamente quale aggiornamento è il colpevole. Se il setter si trova all'interno di un effetto, chiediti se hai effettivamente bisogno dello stato in quel punto. A volte gli sviluppatori impostano uno stato locale partendo dalle prop all'interno di un effetto, quando potrebbero semplicemente usare la prop direttamente nel JSX.

La lezione fondamentale

L'errore "Maximum update depth" non è un misterioso bug di React. È una rete di sicurezza. Significa che il tuo componente sta cercando di renderizzarsi di nuovo invece di attendere un segnale esterno. Rompi l'abitudine di trattare i render come eventi che dovrebbero produrre altro stato. Tratta i render come pure conseguenze dello stato, non come cause. Mantieni gli aggiornamenti dello stato all'interno di event handler, callback o effetti con dipendenze scelte con cura, e non vedrai mai più questo errore.