Les patterns Provider & Higher-Order Component avec React
#Le pattern provider
Beaucoup de bibliotèques React ont besoin de faire passer des data au travers de tout l'arbre de composants de votre app. Par exemple Redux a besoin de passer son store et React Router doit passer l'objet location. Tout ça pourrait possiblement passer par du shared mutable state (état global mutable, ce qui est rarement une bonne idée). Le shared mutable state rend impossible une application à plus d'un contexte. En d'autres mots, ça ne marcherait que sur le client, où l'état global correspond à celui de l'utilisateur. Si vous décidez de rendre la page côté serveur, c'est impossible de reposer sur une telle implémentation : cet état ne doit pas dépasser le scope de la requête courante au serveur.
Coup de bol, l'API de React nous offre une solution à ce problème: le
context
. Si l'on résume
sa nature, c'est comme l'objet global de votre arbre de composants.
Le context
fonctionne de la façon suivante:
- On définit haut dans notre app un
context
que l'on donne aux composants descendants de l'app - On récupère ce contexte dans les composants descendants.
Du coup, pour donner ce context
, on doit avoir un Provider
. Son rôle est
simplement de fournir un context
pour que les composants enfants y aient
accès.
On va illustrer ce pattern avec un use-case très simple : dans notre app, les utilisateurs peuvent customiser le thème.
import React, { Component, PropTypes, Children } from "react";
class ThemeProvider extends Component {
// la méthode getChildContext est appelée pour fournir le `context`
// dans notre cas, on le récupère des `props`
getChildContext() {
return {
theme: this.props.theme
};
}
// on render l'enfant
render() {
return Children.only(this.props.children);
}
}
ThemeProvider.propTypes = {
theme: PropTypes.object.isRequired
};
// pour que React prenne en compte le context fourni,
// on doit définir les types des propriétés que l'on passe
ThemeProvider.childContextTypes = {
theme: PropTypes.object.isRequired
};
export default ThemeProvider;
Pour utiliser le provider, il suffit de wrapper notre app avec:
import React from "react";
import ReactDOM from "react-dom";
import ThemeProvider from "ThemeProvider";
import App from "App";
const theme = {
color: "#cc3300",
fontFamily: "Georgia"
};
ReactDOM.render(
<ThemeProvider theme={theme}>
<App />
</ThemeProvider>,
document.querySelector("#App")
);
Maintenant que notre theme
est bien ajouté au context
, on a besoin d'un
moyen simple pour que nos composants dans l'app puissent le consommer. Ça nous
amène au second pattern.
#Le pattern Higher-Order Component
Afin de consommer le context
, un component doit définir une propriété statique
contextTypes
stipulant quelles propriétés le composant souhaite récupérer. On
pourrait le définir sur chaque composant, mais cela serait une mauvaise idée
pour deux raisons :
- La maintenabilité : si à un moment, on a besoin de refacto, avoir tous ces
contextTypes
éparpillés dans notre repository peut faire bien mal. - La complexité: L'API des
context
étant encore obscure pour beaucoup, il est préférable de faire une abstraction pour la masquer.
Une autre solution serait d'utiliser l'héritage d'une sous-classe de
ReactComponent
. Ça ne marche pas pour deux raisons:
- Plus d'un niveau d'héritage est en général une idée de merde. Cela mène
souvent à des conflits entre méthodes, et force à vérifier toute la chaîne
d'héritage à chaque fois que l'on souhaite modifier quelque chose. L'API des
mixins
deReact.createClass
réglait ce souci en définissant des comportements de merge selon les méthodes, mais cela rend encore plus obscure la compréhension du fonctionnement de nos composants. - Si l'on veut des APIs interopérables, on ne peut pas partir de l'héritage.
React offre trois moyens de définir un composant:
class extends React.Component {}
,React.createClass({})
et(props) => ReactElement
. Les deux derniers ne peuvent pas bénéficier de l'héritage.
La meilleure façon de créer une fonctionnalité réutilisable est d'utiliser le
pattern du Higher Order Component (ou HOC). Ce que ça veut dire, c'est
qu'on va simplement wrapper un composant dans un autre, lequel a pour unique
rôle d'injecter la fonctionnalité et de la passer via les props
. Il s'agit
tout bêtement du principe de composition : au lieu d'exporter A
, vous exportez
Wrapped(A)
, et ce dernier retourne un composant React qui va appeler A
dans
sa méthode render
.
Pour le voir simplement, il s'agit d'un point intermédiaire dans l'arbre de vos
composants, qui injecte quelques props
. Il existe beaucoup d'avantages
apportés par ce pattern :
- Isolation : Il n'y a pas de risque de collision de propriétés au sein du composant.
- Interopérabilité : Ce principe s'adapte à tout composant React, peu importe la façon dont il a été défini.
- Maintenabilité : Le wrapper n'aura qu'une seule fonctionnalité, ce qui le
rend plus simple à comprendre. De plus, si l'on utilise le
context
, on ne trouvera le mappingcontextTypes
qu'à un seul endroit dans l'app.
import React, { Component, PropTypes } from "react"
const themed = (ComponentToWrap) => {
class ThemeComponent extends Component {
render() {
const { theme } = this.context
// le component va render `ComponentToWrap`
// mais il va y ajouter la prop `theme`, qu'il récupère du `context`
return (
<ComponentToWrap {…this.props} theme={theme} />
)
}
}
// on définit ce qu'on veut consommer du `context`
ThemeComponent.contextTypes = {
theme: PropTypes.object.isRequired,
}
// on retourne notre wrapper
return ThemeComponent
}
export default themed
Pour utiliser notre HOC, il suffira d'exporter nos composants wrappés :
import React from "React";
import themed from "themed";
const MyStatelessComponent = ({ text, theme }) => (
<div style={{ color: theme.color }}>{text}</div>
);
export default themed(MyStatelessComponent);
Puisqu'il s'agit simplement d'une fonction, on peut y passer des options à l'aide d'une simple closure.
const defaultMergeProps = (ownProps, themeProps) => ({ ...ownProps, ...themeProps })
const theme = (mergeProps = defaultMergeProps) =>
(ComponentToWrap) => {
// …
render() {
const { theme } = this.context
const props = mergeProps(this.props, { theme })
return (
<ComponentToWrap {…props} />
)
}
// …
}
et l'utiliser de cette façon :
const mergeProps = (ownProps, themeProps) => ({
...themeProps,
color: themeProps.theme.color
});
export default theme(mergeProps)(MyComponent);
Une astuce sympathique lorsque vous utilisez plusieurs HOC, c'est de les
composer, puisque compose(A, B, C)(props)
vaudra A(B(C(props)))
, par exemple
:
const composed = compose(connect(mapStateToProps), theme());
export default composed(MyComponent);
Bisous bisous.