Foto Andrea Mangano
Andrea Mangano

Pillole di React: Gestione degli errori con Error Boundaries

14 ottobre 2018
10m
Come gestire errori in React evitando di corrompere l'intera applicazione? In questo tutorial introduco il concetto di Error Boundary, l'implementazione del componente e un esempio completo che ti suggerisce come utilizzare gli Error Boundaries all'interno della tua applicazione.
React.js
Error Boundaries
React 16
Gestione Errori
Design Pattern
Tutorial
Condividi su

Contesto e finalità del tutorial

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:

  • cosa è un Error Boundary e quando va utilizzato
  • come creare un Error Boundary all’interno di una mini applicazione d’esempio
  • come un Error Boundary gestisce e limita gli errori sui porzioni differenti d’interfaccia
  • come gestire errori su gestori di eventi

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.

Introduzione all’Error Boundary

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:

  • Gli Error Boundaries catturano errori JavaScript ovunque all’interno dei componenti figli durante il render o nei metodi del lifecycle
  • Sono realizzati attraverso componenti che implementano il metodo componentDidCatch(error, info)

Bada bene, gli Error Boundaries NON sono in grado di catturare:

  • errori su se stessi
  • errori per i gestori di eventi
  • errori su codice asincrono
  • errori nel rendering lato server

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.

Struttura di un Error Boundary

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.

Creazione di un componente Error Boundary

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:

  • un componente che genera (appositamente) un errore
  • un componente di Error Boundary che lo cattura fornendo all’utente un’interfaccia di fallback

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?

  • Il metodo componentDidCatch() verrà invocato quando uno dei componenti figli di Error Boundary scatenerà un qualsiasi tipo di errore legato ai metodi del lifecycle;
  • Sarà a quel punto che ErrorBoundary cattura l’errore (e le sue info aggiuntive) salvando i riferimenti nello stato del componente per renderli disponibili all’interno della classe;
  • Tale aggiornamento dello stato provocherà una nuova invocazione del metodo di render()
  • Quando la funzione di render è chiamata, controllerà la presenza di errori nello stato, quindi procederà a renderizzare un’interfaccia di fallback che rimpiazzerà la vista dei componenti figli.

Utilizzo di un Error Boundary

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:

null

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).

null

Gestione errori su più componenti figli

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>

null

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:

null

È 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

Gestione errori su gestori di eventi

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:

  • catturiamo l’errore tramite un classico blocco try/catch
  • registriamo l’esistenza dell’errore dentro lo stato del componente
  • l’aggiornamento dello stato avvia un nuovo ciclo di rendering
  • Durante il rendering, il dato sull’errore è disponibile, possiamo intercettarlo e lanciare un apposito errore attraverso l’istruzione 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.

Legacy break

L’introduzione degli Error Boundaries ha importanti risvolti in termini di legacy.

Arresti anomali non rilevati prima

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.

Metodo unstable_handleError deprecato

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.

Pillole di React: Compound Component

30 ottobre 2018
React.js
Compound Component
Advanced Design Pattern
Tutorial
L'autore
Foto di Andrea Mangano

Andrea Mangano

Frontend Developer @ iBuildings

Da più di otto anni, costruisco interfacce utente per siti e applicazioni web commerciali, con constante curiosità ed entusiasmo per le nuove tecnologie.

Professionalità, tenacia e propensione al rischio sono i miei tratti distintivi.

Seguimi su twitter: @andreaman87