Ti sarà sicuramente capitato, durante la scrittura di applicazioni in React, di imbatterti in errori che, generati all’interno del ciclo di vita di un componente, compromettono l’intero funzionamento dell’applicativo.
L’intero albero dei componenti viene smontato a partire dalla radice e l’utente non vedrà il dato corrotto contestualizzato, bensì una pagina vuota. Sarai sicuramente concorde che a livello di UX non è sicuramente lo scenario migliore.
Per far fronte a questo problema, dalla versione 16 di React, è stata introdotta una gestione migliorata degli errori tramite gli Error Boundaries.
Un errore generato da una porzione di interfaccia non dovrebbe mai rompere l’intero funzionamento della nostra applicazione.
L’obiettivo degli Error Boundaries è limitare il propagarsi dell’errore tramite una gestione localizzata.
In questo breve tutorial comprenderai:
Trovi un esempio dimostrativo completo su https://codesandbox.io/s/kx2q701wj5
Nota: Per una maggiore comprensione dei concetti di seguito presentati, assicurati di aver compreso al meglio il funzionamento del lifecycle di un componente React.
Un Error Boundary è un componente React che cattura (e gestisce) eventuali errori scatenati dai suoi figli fornendo all’utente un’interfaccia di fallback. Come il nome stesso afferma, gli Error boundary limitano il propagarsi dell’errore all’interno della struttura dei componenti dell’app.
In particolare:
componentDidCatch(error, info)
Bada bene, gli Error Boundaries NON sono in grado di catturare:
I componenti di tipo Error Boundary possono essere specifici quanto vuoi. Possono includere la gestione di errori per una parte d’interfaccia che svolge una singola funzionalità così come per gestire gli errori di un set di componenti.
Come in precedenza accennato, un componente di Error Boundary fa uso del metodo del lifecycle componentDidCatch()
che, nella sua logica, è molto simile al classico costrutto di try / catch.
Il metodo componentDidCatch() viene invocato con due parametri: error
e info
che rispettivamente forniscono l’errore generato e informazioni aggiuntive sul contesto dove esso si genera (intero stack dell’errore).
Nota che, l’uso di un metodo del component lifecycle, determina necessariamente la creazione di un componente di tipo Class.
Ecco come si presenta lo scheletro di un componente di Error Boundary in una caso generico:
class ErrorBoundary extends React.Component {
state = { hasError: false };
// Definisce il metodo del lifecycle che verrà invocato al verificarsi di un errore
componentDidCatch(error, info) {
// Resistra il verificarsi dell'errore nello stato del componente
this.setState({ hasError: true });
// A questo punto, potrebbe anche inviare l'errore ad un servizio di logging dedicato
logError(error, info)
}
render() {
if (this.state.hasError) {
// Renderizza una UI di fallback a fronte di un errore
return <h1>Errore!</h1>;
}
// Altrimenti renderizza normalmente i componenti figli
return this.props.children;
}
}
Una volta definito, può essere usato all’interno dell’applicazione come qualsiasi altro componente:
<ErrorBoundary>
<MyComponent />
</ErrorBoundary>
Il metodo componentDidCatch() funziona come un blocco catch JavaScript.
L’Error Boundary cattura gli errori nei componenti del proprio sotto albero: se non riesce a gestire l’errore, fornendo appositamente l’interfaccia di fallback, l’errore si propagherà fino all’Error Boundary più vicino nella struttura sopra di esso (qualora qualche altro Error Boundary sia utilizzato).
Anche tale comportamento è molto simile al modo di operare del blocco catch.
Mettiamo all’opera i concetti appena visti con un esempio pratico che dia una visione d’insieme.
A tale fine, utilizzo un editor online (https://codesandbox.io) per costruire velocemente una mini applicazione di prova che sia provvista di:
Per semplicità, creo un componente pulsante (BuggyButton) che mi permetterà di scatenare un errore quando viene cliccato.
class BuggyButton extends React.Component {
state = { hasError: false };
handleClick = () => this.setState({ hasError: true });
render() {
if (this.state.hasError) {
throw new Error("I crashed!");
}
return <button onClick={this.handleClick}>Buggy Button</button>;
}
}
Creo quindi un componente Error Boundary che gestisce, in maniera del tutto generica, gli errori scatenati dal componente BuggyButton (o di qualsiasi altro componente figlio che si voglia aggiungere).
class ErrorBoundary extends React.Component {
state = {
error: null,
errorInfo: null
};
// Viene invocato quando si generano errori sui componenti figli
componentDidCatch(error, errorInfo) {
// Cattura gli errori salvandoli sullo stato
this.setState({ error, errorInfo });
}
render() {
const { error, errorInfo } = this.state;
// Verifica se è stato catturato un errore
// controllando la variabile error salvata sullo stato
if (error) {
// Renderizza una UI di fallback
return (
<div style={{ border: "2px solid red", color: "red", padding: "1rem" }}>
<h4>Si è verificato un errore.</h4>
<div style={{ whiteSpace: "pre-wrap" }}>
{error.toString()}
<br />
{errorInfo.componentStack}
</div>
</div>
);
}
// Se non ci sono errori, renderizza normalmente i componenti figli
return this.props.children;
}
}
Ok, analizziamo il codice scritto per l’Error Boundary e il suo flusso logico.
In un contesto privo di errori, la funzione di render restituirà i nodi e i componenti figli (reperibili dalla prop children
), renderizzando normalmente la vista.
Cosa succede contrariamente?
componentDidCatch()
verrà invocato quando uno dei componenti figli di Error Boundary scatenerà un qualsiasi tipo di errore legato ai metodi del lifecycle;render()
Vediamo come utilizzare il componente di Error Boundary creato e il suo comportamento all’interno di una mini applicazione di prova.
Creo quindi il componente principale App. Al fine di rendere l’esempio più semplice ed immediato possibile, ho inserito le dichiarazioni dei componenti BuggyButton e ErrorBoundary nel file principale dell’applicazione.
Sarà tua premura, in un contesto reale creare dei file appositi per garantire modularità e riutilizzo del codice.
import React from "react";
import ReactDOM from "react-dom";
// Definizione del Componente BuggyButton
class BuggyButton extends React.Component { ... }
// Definizione del Componente di ErrorBoundary
class ErrorBoundary extends React.Component { ... }
// Componente radice della nostra applicazione
function App() {
return (
<div>
<h1>
Questo è un semplice esempio degli Error Boundaries in React (versione
16 in poi)
</h1>
<p>Clicca il pulsante per generare l'errore.</p>
<ErrorBoundary>
<BuggyButton />
</ErrorBoundary>
</div>
);
}
const rootElement = document.getElementById("root");
ReactDOM.render(<App />, rootElement);
Come puoi notare dal codice JSX della vista, il componente ErrorBoundary provvede alla gestione degli errori per un solo figlio:
<ErrorBoundary>
<BuggyButton />
</ErrorBoundary>
Questa immagine raffigura come dovrebbe presentarsi l’interfaccia dell’applicazione:
Cliccando sul pulsante BuggyButton si genera l’errore. L’ErrorBoundary lo cattura e visualizza la UI di fallback prevista (Scritta “Si è verificato un errore” e stack errore).
Nell’esempio precedente il componente di Error Boundary cattura e gestisce l’errore scatenato dall’unico figlio presente. Vediamo cosa succede se inseriamo due componenti BuggyButton all’interno di un’unico componente ErrorBoundary insieme ad un testo di prova:
<ErrorBoundary>
<div>Testo di prova all'interno dell'error boundary</div>
<BuggyButton />
<BuggyButton />
</ErrorBoundary>
Se uno dei pulsanti viene cliccato, si genera un errore. L’interfaccia di fallback rimpiazzerà tutti i figli nella sotto alberatura, ovvero i due pulsanti BuggyButton e il testo di prova:
È evidente che, una gestione grossolana degli errori, porta alla perdita di una parte di componenti d’interfaccia non direttamente interessati all’errore.
Puoi accedere ad un’esempio completo dei concetti finora visti: https://codesandbox.io/s/kx2q701wj5
Come detto precedentemente in questo tutorial, gli Error Boundaries non catturano gli errori all’interno dei gestori di eventi. Ciò non avviene perché i gestori di eventi non sono invocati durante il ciclo di vita del componente.
Partiamo da un esempio. Sostituisco la classi BuggyButton (vista in precedenza) con questa nuova definizione:
// Nuova definizione componente BuggyButton
class BuggyButton extends React.Component {
// Gestore evento click
handleClick = () => {
throw new Error("I crashed!");
};
render() {
return <button onClick={this.handleClick}>Buggy Button</button>;
}
}
// Precedente definizione componente BuggyButton
class BuggyButton extends React.Component {
state = { hasError: false };
handleClick = () => this.setState({ hasError: true });
render() {
if (this.state.hasError) {
throw new Error("I crashed!");
}
return <button onClick={this.handleClick}>Buggy Button</button>;
}
}
Ci aspettiamo che tutto continui a funzionare e che, nello specifico, il nostro componente di ErrorBoundary continui a catturare e gestire l’errore generato.
Con sorpresa per qualcuno, non è cosi. Perché?
Il problema di base è che i gestori di eventi sono asincroni per natura. Essi non sono eseguiti nella stessa esecuzione del codice di eventi degli Error Boundaries.
Per fare un parallelo con un esempio in plain JavaScript, è come se cercassimo di catturare un errore su un’esecuzione di codice asincrona in un blocco try catch.
try {
setTimeout(_ => { throw new Error('Problema generato in un contesto asincrono') }, 0)
} catch (err) {
console.log('Logging dell\'errore', err, info)
}
Ovviamente questo non funziona ed il catch non catturerà mai questo particolare errore.
Come facciamo quindi a scatenare un errore da un gestore di eventi che possa essere catturato dall’ErrorBoundary?
Ciò che ci occorre, è unire i due flussi d’esecuzione (renderizzazione componente e flusso gestore di evento) rendendo disponibili alcuni dati durante il rendering del componente (ovvero dentro la funzione di render()).
In una situazione di errore dentro il gestore di evento:
throw
class BuggyButton extends React.Component {
state = { err: null }
handleClick = () => {
try {
// qualche codice che produce errore
} catch(err) {
this.setState({ err })
}
}
render() {
if (this.state.err) throw this.state.err
// ...
}
}
Il componente ErrorBoundary sarà così in grado di portare avanti il suo lavoro correttamente.
L’introduzione degli Error Boundaries ha importanti risvolti in termini di legacy.
A partire da React 16, gli errori che non sono stati catturati da alcun Error Boundary provocheranno l’unmount dell’intero albero dei componenti di React.
La scelta nasce dalla considerazione che, all’incorrere di un errore non gestito, è meglio rimuovere l’interfaccia che lasciarla corrotta. Immagina ad esempio un’applicazione React di un e-commerce dove la funzionalità di checkout funziona in parte. Cosa potrebbe comportare?
Questa nuova filosofia d’implementazione, può portare, durante una migrazione alla versione 16, ad arresti anomali nell’applicazione prima non noti.
Sebbene la scelta può sembrare critica, un corretto uso degli Error Boundary, può portare ad una maggiore robustezza dell’applicazione di fronte ad errori imprevisti.
Immagina ad esempio un’applicazione con tre separate funzionalità. Qualora una di essa dovesse rompersi, l’utente può continuare ad utilizzare le rimanenti.
Nella versione 15, React prevedeva un supporto limitato per la gestione degli errori attraverso il metodo unstablehandleError_.
Il metodo non è più funzionante dalla versione 16 e dovrà essere sostituito con il metodo componentDidCatch.
Al fine di rendere più semplice il refactoring del codice è stata fornita una specifica codemod.