Injection de props via un provider de context

(le titre est vraiment √† chier ūüėÖ)

Aujourd'hui, je travaille sur un projet consistant à produire une codebase capable de générer des dizaines de sites très semblables au niveau de la structure mais qui possèdent chacun leurs particularité. Jusqu'à maintenant, ce travail s'est fait essentiellement en forkant le précédent site et donc en produisant des sites jetables très difficiles à maintenir. React m'a aidé à mettre en place des composants configurables à plusieurs niveaux et ainsi à générer un site contenant simplement un composant et une configuration. Ce composant génèrera toutes les pages communes à tous les sites, les routes, etc tout en injectant les composants spécifiques au site. Mais comment arriver à injecter tout ça dans les tréfonds de cet énorme composant ?

Ce que je trouve vraiment tr√®s puissant avec React, c'est l'usage quasi illimit√© qu'on peut faire avec les props. Les props sont les entr√©es du composant qui vont √™tre utilis√©es pour g√©n√©rer une sortie. Ces props sont pass√©es au composant lors de son impl√©mentation, mais l√† o√Ļ √ßa devient tr√®s puissant, c'est qu'on peut aussi passer ces props par le biais de HOC, des fonctions qui vont se placer de fa√ßon transparente au dessus du composant ou alors via la d√©finition des props par d√©faut (defaultProps). Avec ces 3 outils, on est capable de faire des composants √† l'impl√©mentation ultra simpliste mais pourtant tr√®s facilement r√©utilisable ou configurables.

Injection par les props

La grosse difficulté que j'ai pu découvrir lors de l'apprentissage de React est venue de la volonté de vouloir reproduire ce que je connaissais d'autres framework plus proches de la spec des WebComponent. Notamment, mon premier gros pédicode a été de vouloir reproduire des slots pour pouvoir passer plusieurs éléments enfants à mon composant. Quelqu'un avait cherché à faire la même chose et avait obtenu une réponse sur StackOverflow, j'ai donc pensé que c'était légitime de procéder de la sorte. Puis plus tard, j'ai compris qu'il était possible et surtout bien plus pratique de passer des composants directement via les props. Il devenait ainsi possible d'injecter absolument ce qu'on voulait à un composant à condition que celui-ci soit conçu de façon à être à l'écoute de ces injections.

Un autre des défauts que je trouvais à React était sa non séparation du rendu et de la logique dans la plupart des tutos. Et c'est ici que j'ai pu trouver une réponse à cette problématique et concevoir tous mes composants sous la forme de deux fichiers : un index.js et un render.js. Le premier s'occupant de récupérer toutes les données nécessaires à passer au render qui lui n'était qu'une bête fonction retournant directement et sans la moindre logique du VDOM. J'injecte ce render à mon index via les props, ce qui me permet de pouvoir changer le rendu du composant simplement en passant une autre fonction à la prop render de mon composant.

Voici donc le modèle de composant que j'utilise :

// MonComposant/index.js
import React from 'react';

import render from './render';

export class MonComposant extends React.Component {  
  static defaultProps = {
    render,
  };

  render() {
    const { render: Render, ...nextProps } = this.props;
    // On peut ici générer ou altérer des props à passer au render
    const label = 'World';
    const props = {
      ...nextProps,
      // On ajoute ici les props générés à passer au render
      label,
    };

    return <Render {...props} />;
  }
};

export default MonComposant;  
// MonComposant/render.js
import React from 'react';

export const MonComposantRender = ({ label }) => (  
  <p>Hello {label}</p>
);
export default MonComposantRender;  

Premier avantage : on se retrouve avec un modèle de séparation rassurant quand on vient du MVC, le template est à part du code. Mais le second avantage, c'est que cette fonction de rendu devient très facilement remplaçable. Ainsi, je pourrais implémenter mon composant de ces multiples façons pour avoir autant de résultats différents sans toucher au code du composant :

<MonComposant />  
<MonComposant  
  render={({ label }) => (
    <h1>Yo {label}</h1>
  )} />
<MonComposant  
  render={({ prefix, label }) => (
    <h1>{prefix} {label}</h1>
  )}
  prefix="Yo" />
<MonComposant  
  render={HelloComponent} />

Toute la logique est préservée, et je peux même injecter d'autres props à mon render personnalisé via le scope parent.

Je peux aussi utiliser ce mod√®le pour injecter des composants √† mon render tout en facilitant leur modification ou leur mocking. En effet, dans le cas suivant o√Ļ mon render va impl√©menter le composant MonHeader, je vais avoir tendance √† importer MonHeader dans le render. Ce composant est alors fig√© dans le marbre, rendant la personnalisation impossible :

// render.js
import React from 'react';  
import MonHeader from './MonHeader;

export default () => (  
  <div>
    <MonHeader />
  </div>
)

Alors qu'il est tout à fait possible d'injecter ce composant via l'index.js et même de le rendre configurable :

// index.js
import React from 'react';  
import MonHeader from './MonHeader;  
import render from './render;

export default class MonComposant extends React.Component {  
  static defaultProps = {
    render,
    HeaderComponent: {
      component: MonHeader,
      title: 'Foo bar',
    },
  };

  render() {
    const { render: Render, HeaderComponent, ...nextProps } = this.props;
    const Header = () => <HeaderComponent.component {...{...HeaderComponent, ...this.props}};
    // L√† c'est la version complexe dans le cas o√Ļ on veut faire transiter directement des props depuis MonComposant jusqu'√† HeaderComponent
    // Mais si on veut juste injecter un composant, on peut faire beaucoup plus simplement :
    // const Header = HeaderComponent.component;
    const props = {
      ...nextProps,
      Header,
    };
    return <Render {...props} />;
  }
}
// render.js
import React from 'react';

export default ({ Header }) => (  
  <div>
    <Header />
  </div>
)
// app.js
// Remplace complètement le composant
<MonComposant  
  HeaderComponent={{
    component: CustomHeader
  }} />
// Remplace uniquement la valeur du `title` passé au composant par défaut
<MonComposant  
  HeaderComponent={{
    title="Bar foo"
  }} />
// Remplace uniquement la fonction de render du composant par défaut, qui va donc prendre en props les props par défaut
<MonComposant  
  HeaderComponent={{
    render={({ title }) => <p>{title}</p>},
  }} />

Je peux juste tester mon render.js en lui passant un mock de Header en prop, je peux tester le composant entier en passant aussi un mock de Header en prop, et je peux passer un Header personnalisé si j'ai besoin d'afficher quelque chose de différent dans certains cas.

Configuration par contexte

Maintenant, allons plus loin avec les hoc et context. Le plan pour que mon objectif soit atteint était d'arriver à générer un site avec seulement ceci :

render() {  
  return (
    <SiteComponent
      config={config} />
  );
}

Rien de plus, rien de moins. Cependant, comment faire si sur l'un des sites, on ne veut pas afficher l'avatar dans le cadre des informations utilisateurs de la vue Dashboard accessible depuis la route /dashboard ? Gr√Ęce au context, et aux hoc.

Mon composant est accompagné d'un Provider qui se charge de récupérer la configuration et de la ranger dans un Context. Ainsi, chacun de mes composants passe par un hoc withSiteConfig donnant accès à la configuration. Mais j'ai aussi un hoc withAutoConfig(path) prenant en paramètre le chemin vers la configuration du composant. Les props du composant seront alors automatiquement remplies en piochant dans la config passée au Provider.

Ainsi, si je veux personnaliser le composant UserCard de la vue Dashboard, je pourrais implémenter mon site de la sorte :

render() {  
  return (
    <Provider
      config={{
        Dashboard: {
          UserCardComponent: {
            component: CustomUserCard,
          },
        },
      }}>
      <SiteComponent />
    </Provider>
  );
}

ou bien plus simplement :

render() {  
  return (
    <Provider
      config={{
        Dashboard: {
          UserCardComponent: {
            render: ({ user }) => <p>Hello {user.name}</p>,
          },
        },
      }}>
      <SiteComponent />
    </Provider>
  );
}

ou encore plus simplement, si le composant le permet, en modifiant ses props :

render() {  
  return (
    <Provider
      config={{
        Dashboard: {
          UserCardComponent: {
            displayAvatar: false,
          },
        },
      }}>
      <SiteComponent />
    </Provider>
  );
}

Mon composant UserCard, lui, sera implémenté de la sorte :

import React from 'react';  
import { withAutoConfig } from '../config';

export class UserCard extends React.Component {  
  /**/
}

export default  
  withAutoConfig('views.Dashboard.UserCardComponent')(
    UserCard
);

Je ne vous présente pas le code de withAutoConfig, mais en gros son fonctionnement est le suivant : il récupère la config du contexte, les propTypes et les defaultProps du composant passé en paramètre, et pour chaque propType, il va ajouter la valeur correspondant à la prop prise par ordre de priorité dans la config, dans la config par défaut du projet puis dans le defaultProps.

En conclusion de cet article, je voudrais saluer la cr√©ativit√© que permet React. C'est probablement pour cet aspect que je m'y suis plu si rapidement. Il est possible d'architecturer ses projets de pleins de fa√ßons diff√©rentes, que √ßa soit par des hocs, par des renderProps, des context, autour de redux, etc. Il n'y a pas qu'un seul mod√®le √† suivre b√™tement comme une IA. Avec les effets n√©gatifs qui sont qu'on peut aussi produire un √©norme plat de p√Ętes impossible √† maintenir ūüėÖ

Hadrien

Hi, I'm a french Javascript Lead Developer, Web Architect from Toulouse, France. I've worked for 12 years for many projects with YUI, AngularJS, Aurelia.io and now React and React native.

Toulouse, France https://hadrien.eu