Écrire des composants réutilisables est (ou devrait être) l’un des objectifs de tout développeur React. Que ce soit pour créer une boîte à outils de composants stylés, pour mutualiser des comportements communs, etc.
Même en ayant fréquemment utilisé des high-order components (avec React-Redux par exemple), ce n’est que récemment que j’ai entendu parler de l’opportunité qu’ils offraient pour écrire des composants réutilisables, en particulier grâce à une fantastique bibliothèque : Recompose. Voyons comment avec un exemple concret et complet.
Si vous n’avez jamais entendu parler de high-order components (HOC) avant, sachez simplement qu’un HOC n’est finalement rien d’autre qu’une fonction qui prend en paramètre une définition de composant (classe ou fonction), et renvoie une nouvelle définition de composant, qui ajoute du comportement à la première. Il s’agit en fait du pattern Décorateur appliqué aux composants React.
Sur le site de React vous trouverez une page très fournie si vous souhaitez en savoir plus sur les HOC. Il y a également un très bon article sur Putain de code présentant les HOC à travers un autre cas d’utilisation (le pattern provider).
Un exemple très simple :
const addBorder = borderWidth => Component => props => (
<div style={{ borderColor: "black", borderStyle: "solid", borderWidth }}>
<Component {...props} />
</div>
);
const MyText = <p>Hello!</p>;
const MyTextWithBorder = addBorder(5)(MyText);
Vous obtenez un composant MyTextWithBorder
qui affiche le texte « Hello » avec
une bordure de 5 pixels. Ici, addBorder
est ce que l’on appelle un high-order
component.
Quel est l’intérêt d’un HOC ? Et bien un pattern très utile est d’extraire un
comportement partagé par plusieurs composants dans des fonctions réutilisables.
Si vous avez utilisé React avec Redux et React-Redux, vous avez sans doute
utilisé le HOC connect
pour faire le mapping de l’état et des actions avec les
propriétés.
Comme exemple complet pour cette article, nous allons utiliser le concept d’HOC pour créer un champ de saisie de numéro de téléphone, qui :
Notez que l’on supposera que notre champs sera contrôlé, c’est-à-dire que nous
utiliserons les propriétés value
et onChange
pour savoir quel texte afficher
et comment le mettre à jour. Nous souhaitons également que la valeur ne
contienne que les chiffres du numéro de téléphone (« 5145550199 »), sans se
soucier de la mise en forme, et donc que le onChange
soit appelé avec les
chiffres uniquement (dans event.target.value
).
Pour rendre notre HOC plus facile à écrire et maintenir, nous utiserons la bibliothèque Recompose, qui propose un grand nombre de fonctions utilitaires pour écrire des HOC. Nous en verrons quelques-unes dans cet article.
Pour développer notre composant nous créerons deux HOC réutilisables, un pour chacun des points ci-dessus. Cela signifie que nous souhaitons que notre composant final soit défini ainsi :
const PhoneNumberInput = formatPhoneNumber(
forbidNonPhoneNumberCharacters(props => <input {...props} />),
);
C’est le bon moment pour introduire la première fonction de Recompose que nous
utiliserons : compose
. Elle effectue la composition de plusieurs HOC pour les
fusionner en un seul, de sorte que nous pouvons écrire plus simplement :
const PhoneNumberInput = compose(
formatPhoneNumber,
forbidNonPhoneNumberCharacters,
)(props => <input {...props} />);
Et parce que nous souhaitons rendre nos HOC aussi réutilisable que possible (pour mettre en forme autre chose que des numéros de téléphone par exemple), rendons-les plus génériques :
// Ne garde que les chiffres, espaces, tirets et parenthèses
const forbiddenCharactersInPhoneNumber = /[^\d\s\-()]/g;
// '5145551234' => '(514) 555-1234'
const formatPhoneNumber = value =>
value.replace(/^(\d{3})(\d{3})(\d{4})$/, "($1) $2-$3");
// '(514) 555-1234' => '5145551234'
const parsePhoneNumber = formattedPhoneNumber =>
formattedPhoneNumber.replace(/[^\d]/g, "").slice(0, 10);
const PhoneNumberInput = compose(
formatInputValue({
formatValue: formatPhoneNumber,
parseValue: parsePhoneNumber,
}),
forbidCharacters(forbiddenCharactersInPhoneNumber),
)(props => <input {...props} />);
Ne trouvez-vous pas cela déjà génial si l’on peut réutiliser uniquement nos deux HOC pour mettre en forme des montants, des numéros de sécurité sociale, tout et n’importe quoi, juste en utilisant les bons paramètres ? 😉
Le point réellement intéressant est qu’ici j’utilise le composant <input>
de
base, mais nous pourrions utiliser n’importe quel composant, tant qu’il utilise
les propriétés value
, onChange
et onBlur
. Donc on peut imaginer utiliser
notre champs de saisie de numéros de téléphone avec React Native, Material-UI,
etc.
Ok, maintenant passons au plus important, écrire nos deux HOC en utilisant les fonctions que Recompose nous met à disposition.
L’idée ici est que lorsque la valeur de l’input est changée (évènement
onChange
), on intercepte cet évènement pour supprimer tout caractère interdit
de la valeur, puis on appelle la propriété onChange
parente avec la valeur
propre.
Nous utiliserons ici la fonction withHandlers
pour ajouter des nouveaux
handlers d’évènement comme propriétés du composant encapsulé. Le bon point est
que nous avons accès aux propriétés de notre composant (ici nous utiliserons
onChange
) pour créer notre nouveau handler :
const forbidCharacters = forbiddenCharsRegexp =>
withHandlers({
onChange: props => event => {
// N’oublions pas que `onChange` n’est pas une propriété requise
// (même si rien ne se produira si elle est absente).
if (props.onChange) {
const value = event.target.value;
const cleanValue = value.replace(forbiddenCharsRegexp, "");
// On ne modifie pas l’évènement original, mais on le clone
// en y redéfinissant event.target.value avec la valeur propre.
const newEvent = {
...event,
target: { ...event.target, value: cleanValue },
};
// On réémet notre évènement au `onChange` parent.
props.onChange(newEvent);
}
},
});
Souvenez-vous qu’autant que possible le composant que nous créons à partir d’un autre doit respecter l’interface de ce dernier. Il doit donc accepter les mêmes propriétés avec le même type.
À présent si nous souhaitons par exemple créer un champ n’acceptant que les chiffres, nous pouvons écrire :
const NumericField = forbidCharacters(/[^\d]/g)(props => <input {...props} />);
Nous avons maintenant notre premier HOC pour interdire certains caractères; écrivons à présent le deuxième, légèrement plus complexe, pour mettre en forme la valeur entrée par l’utilisateur.
Pour notre deuxième HOC, nous devrons avoir dans notre composant un état local pour stocker la valeur entrée dans le champs sans la passer au composant parent. N’oubliez pas que nous souhaitons mettre en forme la valeur uniquement lorsque le focus sort du champs (évènement blur).
Recompose définit une fonction très simple pour ajouter un état local à un
composant : withState
. Elle prend en paramètre le nom de l’attribut dans
l’état (qui sera donné comme propriété au composant enfant), le nom de la
propriété contenant la fonction pour mettre à jour cet état (également donnée
comme propriété), et la valeur initiale (valeur statique, ou bien fonction
prenant en paramètre les propriétés et retournant la valeur initiale).
Pour ajouter notre état local nous écrirons :
withState(
"inputValue",
"setInputValue",
// `formatValue` est l’un des paramètres de notre HOC
props => formatValue(props.value),
);
Facile, non ? 😉
Maintenant que l’on a notre état, nous devons le mettre à jour lorsque la valeur
de l’input est modifiée, donc nous définirons un handler onChange
personnalisé
:
withHandlers({
onChange: props => event => {
props.setInputValue(event.target.value)
}
// ...
Et à l’évènement blur, nous mettrons en forme la valeur, appelerons les
onChange
et onBlur
parents, puis mettrons en forme également la valeur
affichée :
// ...
onBlur: props => event => {
// parseValue est l’autre paramètre de notre HOC
const parsedValue = parseValue(props.inputValue)
const formattedValue = formatValue(parsedValue)
props.setInputValue(formattedValue)
// On ne modifie pas l’évènement original, mais on le clone
// en y redéfinissant event.target.value avec la valeur propre.
const newEvent = {
...event,
target: { ...event.target, value: parsedValue }
}
if (props.onChange) {
props.onChange(newEvent)
}
if (props.onBlur) {
props.onBlur(newEvent)
}
}
)
La dernière étape pour notre HOC consiste à nous assurer que seules les
propriétés acceptées par <input>
lui seront passées. Pour cela on utilisera la
fonction mapProps
de Recompose pour créer un nouvel objet de propriétés à
partir des propriétés existantes, ainsi que la fonction omit
de lodash pour
exclure certaines propriétés d’un objet pour en créer un nouveau :
mapProps(props => ({
...omit(props, ["inputValue", "setInputValue"]),
value: props.inputValue,
}));
En assemblant le tout avec compose
, on obtient :
const formatInputValue = ({ formatValue, parseValue }) =>
compose(
withState("inputValue", "setInputValue", props => formatValue(props.value)),
withHandlers({
onChange: props => event => {
props.setInputValue(event.target.value);
},
onBlur: props => event => {
const parsedValue = parseValue(props.inputValue);
const formattedValue = formatValue(parsedValue);
props.setInputValue(formattedValue);
const newEvent = {
...event,
target: { ...event.target, value: parsedValue },
};
if (props.onChange) {
props.onChange(newEvent);
}
if (props.onBlur) {
props.onBlur(newEvent);
}
},
}),
mapProps(props => ({
...omit(props, ["inputValue", "setInputValue"]),
value: props.inputValue,
})),
);
Et voilà ! Nous avons deux high-order components, on peut les utiliser pour créer notre champs de saisie de numéro de téléphone ! Ci-dessous vous trouverez le JSFiddle content le code source complet de cet exemple, et vous permet de tester le résultat. N’hésitez pas à forker le JSFiddle pour jouer avec Recompose ou créer vos propres HOC.
Écrire des composants réutilisables est tout à fait possible sans utiliser de
HOC. Pour reprendre notre exemple de champ de saisie de numéro de téléphone nous
pourrions créer un composant FormattedInput
, qui prendrait en paramètres
(propriété) les caractères autorisés d’une part, et les fonctions de formattage
d’autre part. Il resterait un inconvénient à cette méthode : cela vous contraint
à n’utiliser qu’un type de champ fixé à l’avance, par exemple <input>
.
C’est là qu’intervient un concept particulièrement intéressant : celui des render props. Tout simplement, cela consiste à passer en propriété de votre composant une fonction permettant de générer un autre composant, qui sera une composante du premier.
Par exemple notre PhoneNumberInput
pourrait nous laisser la possibilité de lui
indiquer comment générer un champ de saisie (sur lequel il ajoutera le
comportement spécifique, ici la mise en forme) :
<PhoneNumberInput renderInput={inputProps => <input {...inputProps} />} />
Il y aurait de quoi écrire un article entier sur les render props, et selon moi il serait dommage de les voir comme une sorte de « concurrent » des HOC ; les deux peuvent répondre à des problématiques parfois similaires, parfois différentes.
Pour en savoir plus sur les render props la page consacrée de la documentation de React est très détaillée et donne également de bons exemples d’utilisation.
J’espère que cet article vous a donné envie d’en savoir plus à propos de Recompose et des high-order components en général. Je suis convaincu que les HOC créent une nouvelle manière d’écrire des composants réutilisables ; on en entendra sans aucun doute parler de plus en plus dans le futur 😀.
Quelques ressources pour aller plus loin :
Cet article est (pour la plus grande partie) la traduction en français de mon article initialement en anglais disponible sur mon blog : Create reusable high-order React components with Recompose.