Foto Andrea Mangano
Andrea Mangano

Pillole di React: Compound Component

30 ottobre 2018
12m
Compound Component? Probabilmente ne hai solo sentito parlare, ma è un pattern di sviluppo utile durante lo sviluppo di componenti d'interfaccia. In questo tutorial mostro l'implementazione del pattern mediante un esempio pratico che evidenzia i problemi risolti e i vantaggi nell'uso.
React.js
Compound Component
Advanced Design Pattern
Tutorial
Condividi su

Finalità del tutorial

Compound Component è un pattern di sviluppo avanzato, dove alcuni componenti sono utilizzati insieme in modo da condividere uno stato implicito che consente di comunicare tra loro.

Per rendere l’idea, Kent C. Dodds (@kentcdodds) utilizza un paragone molto efficace:

“Pensa i Compound Components allo stesso modo degli elementi <select> e <option> in HTML. Separati non fanno molto, ma insieme ti permettono di creare l’esperienza completa.”

Quando un’opzione è cliccata, implicitamente la select ne è consapevole.

In questo tutorial:

  • Creo un componente ImageGallery, inizialmente senza l’utilizzo dei Compound Component.
  • Più avanti tra le righe, riscrivo lo stesso Componente passo dopo passo con l’ausilio del pattern.

Tutto ciò ti permetterà di comprendere nel dettaglio il pattern e di utilizzarlo con sicurezza nei tuoi progetti.

Qui sotto un’anteprima del risultato finale:

Dal classico Componente ai Compound Component

Per comprenderne appieno i vantaggi, procedo con un esempio pratico che migliorerò passo dopo passo all’insorgere di restrizioni e necessità.

Immaginiamo di dover realizzare un semplice componente ImageGallery che permetta di:

  • avere una lista di anteprime foto
  • selezionare una foto da una lista di anteprime (e mostrarla ingrandita)
  • evidenziare la foto correntemente selezionata nella lista
  • mostrare una barra di status (indicefotocorrente / #foto_totali)

Passeremo al componente (tramite props) un array di oggetti foto.

const galleryData = [
  {
    src: "1.jpeg",
    alt: "Testo alt foto 1"
  },
  {
    src: "2.jpeg",
    alt: "Testo alt foto 2"
  },
  {
    src: "3.jpeg",
    alt: "Testo alt foto 3"
  },
  {
    src: "4.jpeg",
    alt: "Testo alt foto 4"
  }
];

Il componente gestisce (nel suo stato) un parametro current che terrà memoria della foto correntemente selezionata.

Una prima implementazione, senza l’uso del pattern Compound Component, potrebbe realizzarsi in qualcosa del genere:

// Prima implementazione componente ImageGallery senza l'uso del pattern Compound Component
class ImageGallery extends React.Component {
  // Stato del componente: inizialmente la slide seleziona è la prima nell'array di foto
  state = {
    current: 0
  };
  // Funzione per memorizzare la foto selezionata al click su una delle anteprime
  clickPreviewHandler = index => () => {
    this.setState({ current: index });
  };
  render() {
    const { items } = this.props;
    const { current } = this.state;
    const currentItem = items[current];
    return (
      <div className="ImageGallery">
        <div className="Picture">
          <img src={`images/${currentItem.src}`} alt={currentItem.alt} />
        </div>
        <div className="PreviewListStatus">
          {current + 1} / {items.length}
        </div>
        <div className="PreviewList">
          {items.map(({ src, alt }, i) => (
            <div
              className={`PreviewList__item ${
                current === i ? "is-active" : ""
              }`}
              key={alt}
              onClick={this.clickPreviewHandler(i)}
            >
              <img src={`images/${src}`} alt={alt} />
            </div>
          ))}
        </div>
      </div>
    );
  }
}
// Uso del componente in pagina
<ImageGallery items={galleryData} />

Edit Pillole di React: Componente ImageGallery V1 senza uso pattern CompoundComponent.

Il componente creato soddisfa tutti i requisiti iniziali. Ma se dovessero cambiare nel tempo?

Ad esempio, supponiamo che venga richiesto (e non è un’ipotesi remota!) di estendere le funzionalità del componente al fine di poter:

  • posizionare la lista di anteprime arbitrariamente sopra o sotto la foto ingrandita
  • ripetere la barra di stato anche sotto il listato di anteprime

In una prima ipotesi, per poter implementare queste nuove funzionalità, ci occorre predisporre nuove proprietà in entrata e cambiare di conseguenza le logiche del componente.

Ma attenzione, una parametrizzazione complessa, comporta la gestione di un numero maggiore di casistiche possibili nella render della vista del componente. Aumentano di conseguenza le probabilità di introdurre nuovi errori e di corrompere le funzionalità già implementate.

Ammesso che riesca (nonostante le complessità) a provvedere ottimamente il componente con le nuove funzionalità richieste, mi ritroverei di fronte alle stesse problematiche (o a problematiche più complesse) a seguito di nuovi ulteriori requisiti.

Questo si traduce in poca flessibilità del componente nell’uso e nella possibilità di estenderlo; un componente che diverrà ben presto difficilmente manutenibile. Qual è l’alternativa?

ImageGallery con Compound Component

Il principale vantaggio dei Compound Component è la flessibilità.

Chi usa un Compound Component può riposizionare i componenti al suo interno nell’ordine che preferisce, non preoccupandosi dello stato che essi gestiscono e condividono con i componenti figli.

Procedo al refactoring del componente ImageGallery. Realizziamo quindi dei componenti dedicati per ognuna delle sue parti:

  • Picture: questo componente si occupa di mostrare la foto ingrandita
  • PreviewList: visualizza le anteprime delle foto nella galleria permettendone all’utente di selezionarne e ingrandirne una alla volta
  • PreviewListStatus: mostra l’indice della foto corrente e il totale di immagini nella galleria
// Componente Picture
const Picture = ({ src, alt }) => (
  <div className="Picture" key={alt}>
    <img src={`images/${src}`} alt={alt} />
  </div>
);
// Componente PreviewList
const PreviewList = ({ items, clickPreviewHandler, current }) => (
  <div className="PreviewList">
    {items.map(({ src, alt }, i) => (
      <div
        className={`PreviewList__item ${current === i ? "is-active" : ""}`}
        onClick={clickPreviewHandler(i)}
        key={alt}
      >
        <img src={`images/${src}`} alt={alt} />
      </div>
    ))}
  </div>
);
// Componente PreviewListStatus
const PreviewListStatus = ({ current, total }) => (
  <div className="PreviewListStatus">
    {current + 1} / {total}
  </div>
);

Riscrivo il componente ImageGallery utilizzando i sotto componenti appena creati. Ciò garantisce una migliore organizzazione del codice, in termini di comprensione e modularità, ma di certo non permette di avere una maggiore flessibilità in termini di gestione dei componenti figli.

// Componente ImageGallery riscritto utilizzando i sotto componenti dedicati, precedentemente creati
class ImageGallery extends React.Component {
  state = {
    current: 0
  };
  clickPreviewHandler = index => () => {
    this.setState({ current: index });
  };
  render() {
    const { items } = this.props;
    const { current } = this.state;
    const currentItem = items[current];
    return (
      <div className="ImageGallery">
        <Picture src={currentItem.src} alt={currentItem.alt} />
        <PreviewListStatus current={current + 1} total={items.length} />
        <PreviewList
          items={items}
          clickPreviewHandler={this.clickPreviewHandler}
          current={current}
        />
      </div>
    );
  }
}

Edit Pillole di React: Componente ImageGallery V2 senza uso pattern CompoundComponent.

Il layout è ancora hardcoded, preimpostato. Ciò che invece desidero, è che l’utilizzatore del componente determini il modo in cui le cose siano visualizzate. Per ottenere l’obiettivo è necessario introdurre alcuni concetti di JavaScript e React, sfruttati dal pattern Compount Component.

Proprietà statiche

La keywork static è stata introdotta nelle classi JavaScript con l’avvento di ES6. Essa permette di definire metodi di classe e proprietà statiche. Perché ne stiamo parlando?

Assegnando le istanze dei componenti figli a proprietà statiche di Classe, possiamo referenziare i componenti anche al di fuori del componente.

Il vantaggio è servito: potendo accedere ai componenti figli dall’esterno, l’utente utilizzatore può definire in maniera arbitraria e dichiarativa il markup del componente posizionando i componenti figli nell’ordine che preferisce. Per intenderci:

// Definizione di proprietà statiche per il componente ImageGallery per riferirsi dall'esterno ai sotto componenti
class ImageGallery extends React.Component {
  static Picture = Picture;
  static PreviewList = PreviewList;
  static PreviewListStatus = PreviewListStatus;
  // Restituiamo semplicemente i figli descritti all'interno del componente
  render() {
    const {children} = this.props
    return (
      <div className="ImageGallery">{children}</div>
    )
  }
}

Così sarà possibile utilizzare il componente ImageGallery in pagina come segue:

// Uso in pagina del componente ImageGallery
<ImageGallery items={galleryData}>
  <ImageGallery.Picture />
  <ImageGallery.PreviewListStatus />
  <ImageGallery.PreviewList />
</ImageGallery>

Comodo no?

In tal modo otteniamo due grandi risultati:

  • Concediamo una grande flessibilità all’utilizzatore del componente, evitandogli le noie di impostare un alto numero di proprietà sul componente per ottenere il medesimo risultato;
  • Lato sviluppatore, riduciamo la complessità di sviluppo date dalla ridisposizione dei componenti all’interno del componente.

Inoltre, rendiamo nota ed esplicita, la dipendenza d’utilizzo tra componenti e figli e padre.

Nota che, in tal modo, possiamo anche utilizzare uno stesso componente figlio più volte.

Se ricordi, le specifiche richieste mi imponevano di poter:

  • posizionare la lista di anteprime arbitrariamente sopra o sotto l’immagine ingrandita
  • ripetere la barra di stato anche sotto il listato di anteprime

Presto fatto:

// Uso alternativo componente ImageGallery
<ImageGallery items={galleryData}>
  <ImageGallery.PreviewList />
  <ImageGallery.PreviewListStatus />
  <ImageGallery.Picture />
  <ImageGallery.PreviewListStatus />
</ImageGallery>

Ho fatto parecchi passi avanti rispetto all’implementazione iniziale, ma se provo ad utilizzare il componente ImageGallery in pagina (con gli aggiornamenti finora prodotti), l’esecuzione traccerà errori!

Questo perché i componenti figli, non ricevono più i dati a loro necessari attraverso i parametri (props).

Per l’appunto, nella versione precedente, i componenti hardcoded ricevevano i parametri dallo stato dalle props del componente padre (ImageGallery). Anche volendo parametrizzare i componenti nella dichiarazione esplicita appena vista, questo non risulta possibile: alcuni dei dati necessari sono dentro al componente ImageGallery come in una scatola nera.

Per risolvere il problema, occorre un modo per permettere ai componenti figli di condividere implicitamente i dati custoditi nel padre.

Passaggio dati dal padre ai figli in un Compound Component

Per superare questo ennesimo ostacolo, farò uso di due utility fornite da React:

  • React.Children.map: simile al metodo nativo Array.map in JavaScript, scorre i figli diretti di un componente nel suo albero del DOM, permettendo di manipolarli a piacimento.

    React.Children.map(this.props.children, child => {
    ...
    })
  • React.cloneElement: clona e restituisce un nuovo elemento React usando l’elemento come punto di partenza:

    React.cloneElement(child, props)

    La cosa veramente utile di quest’ultima funzione è che essa unisce insieme le props passate all’invocazione del metodo, con eventuali props esplicitamente dichiarate sul componente. I figli così creati sostituiranno quelli esistenti. Nota che si verifica un override delle proprietà definite sull’elemento qualora vengano riassegnate alle props in fase di clonaggio.

Con l’ausilio di questi due metodi, ho tutto quello che mi occorre per accedere ai componenti figli, scorrerli e assegnare loro le proprietà necessarie implicitamente.

// Scheletro componente con l'ausilio delle utility React.Children.map e React.cloneElement
class ImageGallery extends React.Component {
  ...
  render() {
    const { children, ... } = this.props;
    return (
      <div className="ImageGallery">
        {
          // Scorre tutti i componenti definiti dall'esterno come figli del componente
          React.Children.map(children, child => {
        	// Ritorna per ognuno di essi una copia passando eventualmente nuove props
            return React.cloneElement(child, newPropsObj)
          }
        }
      </div>
    );
  }
}

Per passare ad ogni componente figlio i dati ad esso necessari, è indispensabile riuscire ad identificarlo.

Per far questo assegno una proprietà dei componenti React chiamata displayName che mi permetterà di riconoscergli all’interno del ciclo React.Chidren.map prima che siano clonati dalla funzione React.cloneElement.

In genere, non è necessario impostare la proprietà displayName in modo esplicito perché viene dedotto dal nome della funzione o della classe che definisce il componente. Potrebbe essere necessario impostarlo in modo esplicito, se si desidera visualizzare un nome differente per questioni di debug o nella creazione di un High Order Component (HOC). In generale, per maggiore robustezza, preferisco comunque dichiararlo.

// Componente Picture
const Picture = ({ src, alt }) => (
  ...
);
Picture.displayName = "Picture";
// Componente PreviewList
const PreviewList = ({ items, clickPreviewHandler, current }) => (
  ...
);
PreviewList.displayName = "PreviewList";
// Componente PreviewListStatus
const PreviewListStatus = ({ current, total }) => (
  ...
);
PreviewListStatus.displayName = "PreviewListStatus";

Utilizzo il displayName in un costrutto switch per identificare ogni componente:

class ImageGallery extends React.Component {
  static Picture = Picture;
  static PreviewList = PreviewList;
  static PreviewListStatus = PreviewListStatus;
  state = {
    current: 0
  };
  clickPreviewHandler = index => () => {
    this.setState({ current: index });
  };
  render() {
    const { children, items } = this.props;
    const { current } = this.state;
    const { clickPreviewHandler } = this;
    const _children = React.Children.map(children, child => {
      let c;
      switch (child.type.displayName) {
        case "Picture":
          c = React.cloneElement(child, items[current]);
          break;
        case "PreviewList":
          c = React.cloneElement(child, {
            current,
            items,
            clickPreviewHandler
          });
          break;
        case "PreviewListStatus":
          c = React.cloneElement(child, {
            current,
            total: items.length
          });
          break;
        default:
          c = React.cloneElement(child);
      }
      return c;
    });
    return <div className="ImageGallery">{_children}</div>;
  }
}

Nel caso di default dello switch considero l’ipotesi di componenti figli non previsti, che clono e mantengo nell’alberatura del DOM.

Non è scontato che si voglia dare la possibilità all’utilizzatore di introdurre nuovi componenti nel markup (oltre quelli previsti). Clonando selettivamente i figli eviterai l’aggiunta di sotto componenti non previsti e possibilmente non voluti (alias non compiere nessun comando nel caso di default).

Riepilogo i passi cruciali di quanto finora visto:

  • Ho creato dei sotto componenti funzionali che ho associato al componente ImageGallery tramite l’uso delle variabili statiche di classe
  • Ho etichettato ogni specifico componente figlio usando la proprietà displayName
  • Ho ciclato i componenti figli usando l’utility React.Children.map
  • Ho clonato ogni figlio passando le occorrenti props attraverso React.cloneElement.

Considerazioni sui limiti della pratica

Ho ottenuto la flessibilità voluta per il componente ImageGallery con l’adozione del pattern Compound Component. Gli scenari di possibile utilizzo sono tanti e avrai sicuramente l’occasione pratica di valutarne l’effettivo vantaggio.

I componenti divengono di immediata comprensione, ordinati, ben strutturati, scalabili e di facile manutenzione.

I Compound Component sono utili anche quando non si vuole dare all’utente la libertà di utilizzare e disporre i componenti figli a piacimento. Essi garantiscono modularità e riutilizzo ottimale del codice.

Immagina ad esempio il caso in cui devi sviluppare diversi componenti Card che utilizzano al loro interno lo stesso insieme di figli (preview, title, abstract, actions,…), dati e funzionalità ma una disposizione di layout differente. Vuoi dare all’utente solo la possibilità di scegliere e utilizzare i componenti card preconfigurate.

In questo scenario potresti creare un componente CommonCard come Combound Component. I componenti Card, che fornirai all’utente, faranno uso del CommonCard component con i figli ridistribuiti nel layout secondo le specifiche della card.

In questo, come in altri casi, potrebbe capitare di dover inserire un sotto componente dentro un semplice div per riorganizzare e stilare il layout.

Immagina ad esempio di inserire un div attorno al componente PreviewList:

// Uso alternativo componente ImageGallery
<ImageGallery items={galleryData}>
  <div>
    <ImageGallery.PreviewList />
  </div>
  <ImageGallery.PreviewListStatus />
  <ImageGallery.Picture />
  <ImageGallery.PreviewListStatus />
</ImageGallery>

Ti aspetti che tutto continui a funzionare, in fondo è cambiato solo un div. Purtroppo non è così e la ragione è semplice.

Il componente PreviewList non è più figlio diretto di ImageGallery. Il codice nel case PreviewList del costrutto switch, non è eseguito. Di conseguenza non sono passate le proprietà di cui necessità e il componente smetterà di funzionare. Questo è uno dei limiti dell’implementazione del Pattern usando React.Children.map e React.cloneElement, che tra l’altro, comporta un maggiore utilizzo in termini di memoria.

React.Children.map(children, child => {
  let c;
  // Considera solo figli diretti
  switch (child.type.displayName) {
    case "Picture":
      ...
    case "PreviewList":
      // Se il componente PreviewList non è più figlio diretto del componente GalleryImage, non entra nel case
      c = React.cloneElement(child, {
            current,
            items,
            clickPreviewHandler
          });
          break;
    case "PreviewListStatus":
      ...
    default:
       c = React.cloneElement(child);
  }
  return c
}

Nel prossimo tutorial vedremo come, l’implementazione dei Compound Component con l’uso dell’API Context, non richiede una struttura markup rigida. Ciò significa che puoi nidificare i tuoi componenti figli nella struttura del DOM, senza alcun problema di comunicazione degli stessi.

Pillole di React: Gestione degli errori con Error Boundaries

14 ottobre 2018
React.js
Error Boundaries
React 16
Gestione Errori
Design Pattern
Tutorial

Pillole di React: Compound Component con Context API

30 ottobre 2018
React.js
Compound Component
Context API
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