Au fur et à mesure qu'une app React grossit, des problèmes de performance peuvent survenir (UI peu réactive, blocage de la page,...). Nous allons voir ici comment fonctionne "en gros" React, et comment optimiser ces performances.
Tout d'abord, commencons par les débuts de React, et notamment :
A quelle(s) problématique(s) répond React ?
Pour faire du "réactif" ?
Il est tout à fait possible de faire du "réactif" (une partie de la page seulement se met à jour en fonction d'actions utilisateur). Par exemple le code suivant affiche un compteur qui s'incrémente au clic sur un bouton, avec un state interne, comme on aurait l'habitude de l'écrire en React
class MyButton {
constructor(id) {
this.dom = document.getElementById(id);
this.state = { value: 0 };
this.onClick = () => {
this.setState({
value: this.state.value + 1,
});
};
this.render();
}
render() {
this.dom.innerHTML = `My counter value is ${this.state.value} <button>click me to inc!</button>`;
this.dom.querySelector('button').addEventListener('click', this.onClick);
}
setState(state) {
this.state = {
...this.state,
...state,
};
this.render();
}
}
export default MyButton;
React gère évidemment bien mieux et plus facilement tout cet aspect "réactif", mais ce n'est pas le besoin premier auquel réponds React
Pour les performances !
Il faut comprendre ce qui se passe dans le navigateur d'un user. Le navigateur va charger la structure HTML, le JS, le CSS, et à partir de là exécuter une phase dite de rendering & painting qui va faire en sorte que l'affichage voulu par le développeur, corresponde à ce que verra l'utilisateur final en appliquant le CSS et le JS. Puis en executant le javascript, qui peut lui aussi agir avec l'UI
Le "rendering" & "painting"
Ces phases de rendering & painting sont extrèment lente (on parle évidemment de millisecondes, mais des millisecondes pour un ordinateur... c'est lent !) A chaque phases de rendering & painting donc, votre navigateur va être "bloqué" et utiliser les ressources de votre ordinateur pour afficher la page. Vous avez surement déjà perçu ces phases, notamment sur des sites qui affichent une grande quantité d'information. Le site semble "bloqué" pendant quelques secondes avant que vous ne récupériez la main. Par exemple https://materialdesignicons.com/ qui est totalement bloqué en arrivant dessus, et prend à chaque recherche plusieurs secondes pour afficher les résultats.
Ainsi, dans l'exemple précédent, tout l'html est re-rendu, donc on relance une phase de rendering & painting à chaque clic sur le bouton. Dans cet exemple, très basique, le temps est négligeable, mais peut très rapidement devenir problématique sur des grosses applications.
React à la rescousse !
Alors comment React permet d'optimiser ces phases ? Bien évidemment, le premier "render" ne peux s'affranchir de ces phases. Mais pour éviter au maximum les rendering & painting suivants, React se base sur un Virtual DOM
Le Virtual DOM de React
Après le permier render de votre application, React va créer un Virtual DOM, c'est à dire une représentation virtuelle du DOM de votre page. A chaque interaction utilisateur, React va générer un nouveau Virtual DOM. Puis en comparant ces 2 Virtual DOM, React est capable de ne modifier dans le DOM réel que les éléments ayant besoin de changer.
Au lieu de recharger toute une partie de la page (voire toute la page), React est donc capable de ne modifier précisement que ce qui a changé entre 2 interactions, réduisant les phases de rendering & painting de ces changements au strict minimum, et donc quasiment imperceptible.
Les comparaisons de Virtual DOM (qui sont des objects javascript en mémoire) sont bien plus rapides que manipuler le DOM directement.
Si on reprend l'exemple du code du début, ce code va re-générer à chaque render tout l'HTML, du div, du button, et réaffecter le onClick
à chaque render, tandis que React ne mettrait à jour que le compteur.
Les limites du Virtual DOM
Le Virtual DOM va dans 90% des cas être suffisant pour avoir des performances entièrement satisfaisante. Pourtant, dans les grosses applications (plusieurs centaines / millier de composants) le Virtual DOM arrive à ses limites.
Prenons en exemple le code suivant, d'après vous, est-ce que le console.log
va être executé à chaque clic sur le bouton du compteur ? (Et donc etre re-render ?)
import React, { Component } from 'react';
class ChildrenComponent extends Component {
render() {
console.log('Rendered!');
return <div>This is a child component</div>;
}
}
class App extends Component {
state = { value: 0 };
onClick = () => {
this.setState(({ value }) => ({ value: value + 1 }));
};
render() {
return (
<>
<button onClick={this.onClick}>Count is {this.state.value}</button>
<ChildrenComponent />
</>
);
}
}
export default App;
On serait tenté de dire "non", et pourtant... le composant ChildrenComponent
est bien re-render à chaque clic sur le bouton du compteur.
Pourquoi ca ? Car à chaque render d'un Component, React va comparer le Virtual DOM de ce composant, ainsi que celui de tous ses enfants (pour vérifier s'il y a besoin de mettre à jour le DOM). Or ChildrenComponent
est un enfant de App
, et comme App
est re-render, tous ses enfant le seront aussi. Si ChildrenComponent
avait aussi des enfants, ils auraient également été re-rendu.
Chaque fois qu'un composant est re-render, React va comparer son Virtual DOM, et celui de ses children
pour vérifier s'il a besoin d'effectuer un changement dans le DOM.
On touche ici la problématique du Virtual DOM, la comparaison systématique des Virtual DOM de composants, qui à priori n'ont pas lieu de devoir changer.
Bien que le Virtual DOM soit plus rapide que manipuler directement le DOM, le Virtual DOM reste un object JS en mémoire assez lourd, et des comparaisons sur plusieurs centaines, voire millier de composants peuvent prendre du temps. La phase d'exécution du JS devient donc le facteur bloquant de l'application
Optimisation des Class Components
Méthode shouldComponentUpdate
React a alors introduit une nouvelle méthode de classe shouldComponentUpdate
qui permet de définir "manuellement" si le composant doit être re-render (en retournant true
) ou non (en retournant false
)
import React, { Component } from 'react';
class ChildrenComponent extends Component {
render() {
console.log('Child 1 Rendered!');
return <div>This is a child component</div>;
}
}
class ChildrenComponent2 extends Component {
render() {
console.log('Child 2 Rendered!');
return <div>This is another child component</div>;
}
shouldComponentUpdate() {
return false;
}
}
class App extends Component {
state = { value: 0 };
onClick = () => {
this.setState(({ value }) => ({ value: value + 1 }));
};
render() {
return (
<>
<button onClick={this.onClick}>Count is {this.state.value}</button>
<ChildrenComponent />
<ChildrenComponent2 />
</>
);
}
}
export default App;
On voit bien ici que seul ChildrenComponent
est re-render, contrairement à ChildrenComponent2
qui implémente un shouldComponentUpdate
qui retourne false
Cette méthode shouldComponentUpdate
vient s'excuter dans le cycle de vie des composants, juste avant le render
, et prend en paramètres nextProps
et nextState
.
nextProps
et nextState
sont les états des props
et du state
de ce composant calculé en vue d'un prochain render. Cela défini l'état "futur" du Composant
Cela permet donc de comparer manuellement this.props
et nextProps
ainsi que this.state
& nextState
. Et en fonction de la comparaison, retourner true
pour re-render le Composant, ou false
pour l'empêcher.
La méthode shouldComponentUpdate
n'est PAS exécutée au render initial
Exemple
import React, { Component } from 'react';
class ChildrenComponent extends Component {
render() {
console.log('Child Rendered!');
return <div>Your name is {this.props.name}</div>;
}
shouldComponentUpdate(nextProps) {
return nextProps.name !== this.props.name;
}
}
class App extends Component {
state = {
value: 0,
name: 'John',
};
onClick = () => {
this.setState(({ value }) => ({ value: value + 1 }));
};
onClickChangeName = () => {
this.setState(({ name }) => ({ name: name === 'Doe' ? 'John' : 'Doe' }));
};
render() {
return (
<>
<button onClick={this.onClick}>Count is {this.state.value}</button>
<button onClick={this.onClickChangeName}>
Change Name to {this.state.name === 'John' ? 'Doe' : 'John'}
</button>
<ChildrenComponent name={this.state.name} />
</>
);
}
}
export default App;
Meme si votre shouldComponentUpdate
retourne false
, il empechera uniquement la phase de render
du composant, mais les props de votre composant auront bien changés !
import React, { Component } from 'react';
class ChildrenComponent extends Component {
render() {
console.log('Child Rendered!');
return (
<div>
Your name is {this.props.name}
<button onClick={() => console.log(this.props.name)}>
Click me to know my inner name
</button>
</div>
);
}
shouldComponentUpdate() {
return false;
}
}
class App extends Component {
state = {
value: 0,
name: 'John',
};
onClick = () => {
this.setState(({ value }) => ({ value: value + 1 }));
};
onClickChangeName = () => {
this.setState(({ name }) => ({ name: name === 'Doe' ? 'John' : 'Doe' }));
};
render() {
return (
<>
<button onClick={this.onClick}>Count is {this.state.value}</button>
<button onClick={this.onClickChangeName}>
Change Name to {this.state.name === 'John' ? 'Doe' : 'John'}
</button>
<ChildrenComponent name={this.state.name} />
</>
);
}
}
export default App;
Que doit-on comparer ?
Tout changement dans les props
ou le state
. React ne propose malheureusement pas une fonction propre aux changement des props
et une propre au changement du state
. Car normalement un changement de state doit provoquer un re-render. Si vous avez un Composant propsless
(sans props
), ce n'est pas la peine de vous encombrer avec un shouldComponentUpdate
. Mais à partir du moment où on a des props, on va également devoir comparer le state.
On va donc, sur chaque Component, comparer les valeurs de nextProps
et this.props
. Ainsi que nextState
et this.state
Et se rendre compte rapidement, que c'est extrêment chronophage. Par exemple si on a 5 props différentes sur un Component, et 2 valeurs dans le state, on va se retrouver à devoir écrire :
shouldComponentUpdate(nextProps, nextState) {
return (
nextProps.prop1 !== this.props.prop1 ||
nextProps.prop2 !== this.props.prop2 ||
nextProps.prop3 !== this.props.prop3 ||
nextProps.prop4 !== this.props.prop4 ||
nextProps.prop5 !== this.props.prop5 ||
nextState.prop1 !== this.props.prop1 ||
nextState.prop2 !== this.props.prop2
)
}
On se tournera donc rapidement vers une fonction "générique" qui permet de tester automatiquement toutes les props et le state, par exemple :
shouldComponentUpdate(nextProps, nextState) {
return (
Object.entries(nextProps).some(([key, value]) => value !== this.props[key]) ||
Object.entries(nextState).some(([key, value]) => value !== this.state[key])
)
}
Mais cela implique d'ajouter ce bout de code sur tous nos Components... Heureusement, React nous offre une solution !
Classe PureComponent
La classe PureComponent
est une classe qui implémente par défaut un shouldComponentUpdate
en comparant nextProps
avec this.props
et nextState
avec this.state
Il suffit donc que notre Component extends PureComponent
au lieu de extends Component
et le tour est joué !
import React, { Component, PureComponent } from 'react';
class ChildrenComponent extends PureComponent {
render() {
console.log('Child Rendered!');
return <div>Your name is {this.props.name}</div>;
}
}
class App extends Component {
state = {
value: 0,
name: 'John',
};
onClick = () => {
this.setState(({ value }) => ({ value: value + 1 }));
};
onClickChangeName = () => {
this.setState(({ name }) => ({ name: name === 'Doe' ? 'John' : 'Doe' }));
};
render() {
return (
<>
<button onClick={this.onClick}>Count is {this.state.value}</button>
<button onClick={this.onClickChangeName}>
Change Name to {this.state.name === 'John' ? 'Doe' : 'John'}
</button>
<ChildrenComponent name={this.state.name} />
</>
);
}
}
export default App;
Si vous utilisez les composants, un rechercher / remplacer dans tous vos fichiers pour remplacer extends Component
par extends PureComponent
et le tour est joué ! (Bon oui, il faut changer les imports aussi)
Optimisation des Function Components
Alors, parfait pour les Composants, mais pour les composants fonction sans Classe ? Comment ca marche vu que ce ne sont pas des classes, et qu'on n'a donc pas accès à la méthode shouldComponentUpdate
?
Reprenons notre exemple précédent, transformé en function component
import React, { useState } from 'react';
const ChildrenComponent = ({ name }) => {
console.log('Child Rendered!');
return <div>Your name is {name}</div>;
};
const App = () => {
const [state, setState] = useState({
value: 0,
name: 'John',
});
const onClick = () => {
setState(state => ({
...state,
value: state.value + 1,
}));
};
const onClickChangeName = () => {
setState(state => ({
...state,
name: state.name === 'Doe' ? 'John' : 'Doe',
}));
};
return (
<>
<button onClick={onClick}>Count is {state.value}</button>
<button onClick={onClickChangeName}>
Change Name to {state.name === 'John' ? 'Doe' : 'John'}
</button>
<ChildrenComponent name={state.name} />
</>
);
};
export default App;
De nouveau, notre ChildComponent
se re-render lors du clic sur le compteur. On perd donc l'optimisation acquise avec les Components.
Hello memo
React nous offre le wrapper memo
qui permet d'avoir le même comportement qu'un shouldComponentUpdate
pour les Class.
import React, { useState, memo } from 'react';
const ChildrenComponent = memo(({ name }) => {
console.log('Child Rendered!');
return <div>Your name is {name}</div>;
});
const App = () => {
const [state, setState] = useState({
value: 0,
name: 'John',
});
const onClick = () => {
setState(state => ({
...state,
value: state.value + 1,
}));
};
const onClickChangeName = () => {
setState(state => ({
...state,
name: state.name === 'Doe' ? 'John' : 'Doe',
}));
};
return (
<>
<button onClick={onClick}>Count is {state.value}</button>
<button onClick={onClickChangeName}>
Change Name to {state.name === 'John' ? 'Doe' : 'John'}
</button>
<ChildrenComponent name={state.name} />
</>
);
};
export default App;
C'est quoi memo
?
Bien que le comportement semble être le même qu'avec un shouldComponentUpdate
, la mécanique est cependant différente.
Les Function Components ne sont ni plus ni moins que des fonctions, qui sont executées pendant les phases de render.
Le fait de wrapper un composant avec memo
va mémoriser (comme une mise en cache) le résultat de l'exécution de la fonction, pour des props
données. Si la fonction est appelée de nouveau avec les mêmes props
, la fonction n'est pas ré-executée mais on va renvoyer directement le résultat de la précédente exécution de cette fonction.
Si une des props
de la fonction change entre 2 appels, la fonction est ré-executée, et son retour est de nouveau mémorisé à la place de l'ancien.
memo
est une fonction de mémorisation à 1 niveau, ce qui signifie que chaque fois qu'une prop
change, la fonction est ré-exectuée, et son retour mis en mémoire. On ne garde toujours que le retour de la dernière exécution en mémoire.
Il existe des librairies qui permettent de faire de la mémorisation à n niveaux, tel que memoizee
Du coup tout est bon, je peux optimiser les re-render inutile aussi simplement ? Réponse courte : oui mais 😀
Il manque une composante importante pour bien comprendre le fonctionnement de ces fonctions. Que nous allons voir tout de suite !
Les pièges de JS !
Comment React fait les comparaisons ?
React va effectuer une comparaison stricte entre les anciennes props
et les nouvelles. C'est à dire une comparaison futureValue !== currentValue
.
Faisons quelques essais
console.log(true === true) // ?
console.log("John" === "John") // ?
console.log(145 === 145) // ?
console.log({} === {}) // ?
console.log([] === []) // ?
console.log(new Date() === new Date()) // ?
console.log((() => null) === (() => null)) // ?
On arrive sur des problématiques qui ne sont pas propres à React, mais à Javascript. Mais qui peuvent rendre toutes nos optimisations précédentes complètement inutiles.
En quoi c'est problématique ?
React fait donc des comparaison stricte entre les anciennes props
et les nouvelles. Et si une des props
est différente, on re-exécute la fonction.
Reprenons notre exemple, en ajoutant un style
à notre ChildComponent
:
import React, { useState, memo } from 'react';
const ChildrenComponent = memo(({ name, style }) => {
console.log('Child Rendered!');
return <div style={style}>Your name is {name}</div>;
});
const App = () => {
const [state, setState] = useState({
value: 0,
name: 'John',
});
const onClick = () => {
setState(state => ({
...state,
value: state.value + 1,
}));
};
const onClickChangeName = () => {
setState(state => ({
...state,
name: state.name === 'Doe' ? 'John' : 'Doe',
}));
};
return (
<>
<button onClick={onClick}>Count is {state.value}</button>
<button onClick={onClickChangeName}>
Change Name to {state.name === 'John' ? 'Doe' : 'John'}
</button>
<ChildrenComponent
name={state.name}
style={{ backgroundColor: 'red', color: 'white' }}
/>
</>
);
};
export default App;
Pourquoi ChildComponent
est re-executé dans ce cas ? Car Javascript ne sait pas comparer des objets (dans le sens où un humain les comparerait)
En Javascript, les Objects (donc les Array, les Functions, les Dates,...) sont stockées sous forme de référence. Lorsqu'on compare 2 objects entre eux, Javascript va comparer les références de ces 2 objets, et en aucun cas leurs valeurs.
const obj = { foo: 'bar' }; // Attribue une reférence "ref1"
const obj2 = { foo: 'bar' }; // Attribue une référence "ref2"
obj === obj2 // "false" car Javascript compare les références, et "ref1" !== "ref2"
obj === obj // "true" car les références sont bien les memes : "ref1"
Vous avez déjà certainement croisé ce comportement sans forcement y prêter attention, lorsque vous manipulez des Objects
const obj = { foo: "bar" };
const obj2 = obj;
obj2.bar = "foo";
console.log(obj2);
console.log(obj);
Vous voyez ici que obj
possède également bar: "foo"
. Car obj2 = obj
ne fait que affecter la référence de obj
à obj2
. Les deux Objects ont donc la même référence, et chaque modification sur l'une ou l'autre des variables modifie le "contenu" de la même référence, donc le même objet.
Prenons maintenant cet exemple
const obj = { foo: "bar" };
const obj2 = { ...obj };
obj2.bar = "foo";
console.log(obj2);
console.log(obj);
Dans ce cas, nous avons bien 2 object différent car obj2
est un nouvel objet (et donc une référence différente) dans lequel nous venons copier toutes les valeurs de obj
On peut illustrer ce concept de référence avec des "tiroirs". Admettons qu'il y ai une étagère avec des tiroirs, nommés A-B-C... par ligne, et 1-2-3-4... par colonne.
Dans le 1er cas, vous placez 2 billes dans le tiroir A1. Un de vos amis vous demande dans quel tiroir vous avez rangé vos affaires, vous lui répondez A1. Vous lui avez donné la référence du tiroir. Votre ami place ensuite une bille supplémentaire dans ce tiroir. Lorsque vous reviendrez ouvrir votre tiroir A1, vous aurez bien 3 billes dedans.
Dans le second cas, vous placez 2 billes en A1. Votre amis va ouvrir un nouveau tiroir en A2, en copiant tout ce qu'il y avait à l'intérieur de votre tiroir A1. Puis rajouter sa bille dedans. Votre tiroir en A1 contient bien toujours uniquement vos 2 billes, tandis que celui de votre amis contiendra 3 billes
Et donc dans notre exemple ?
<ChildrenComponent name={state.name} style={{backgroundColor: "red", color: "white"}} />
A chaque render de notre composant App
, je vais recréer un nouvel objet et le faire passer en tant que prop style
. Donc mon composant ChildrenComponent
va toujours comparer l'actuel style
avec le "futur", et ces 2 objets n'ayant pas la meme référence, ils ne sont donc pas égaux pour Javascript, et la fonction est ré-executée.
Donc bien que notre ChildComponent
soit memo
, l'interet ici de memo
est complètement anihilé par cette prop style
qui changera de toute façon à chaque render de App
.
Pire même, cela va ralentir l'execution de notre app, car à chaque render de App
, React va comparer les props de ChildComponent
alors qu'ils n'ont aucune chance d'etre égaux. On rajoute donc une instruction JS pour rien.
Exemple avec des handlers
import React, { useState, memo } from 'react';
const ButtonInc = memo(({ value, onClick }) => {
console.log(`Render button ${value}`);
return <button onClick={() => onClick(value)}>Inc by {value}</button>;
});
const App = () => {
const [state, setState] = useState({
value: 0,
});
const onClick = inc => {
setState(state => ({
...state,
value: state.value + inc,
}));
};
return (
<>
Count value is {state.value} <br />
<ButtonInc value={1} onClick={onClick} />
<ButtonInc value={5} onClick={onClick} />
<ButtonInc value={-10} onClick={onClick} />
</>
);
};
export default App;
Alors, pourquoi dans ce cas TOUS mes ButtonInc
se re-executent ? Car de la meme manière que Javascript compare des références d'Object, il compare également des références de fonction. Et la fonction onClick
est redéfinie à chaque render, c'est donc une nouvelle référence à chaque fois.
Mais du coup, vu que ce cas arrive dans quasiment tous les composants, memo
est inutile ? Heureusement non, on va maintenant voir les Hooks de mémorisation
Hooks de mémorisation
Objets "statiques" ?
Tout d'abord, le cas le plus "facile", comme on l'a vu avec le style
qui est un objet qui ne change jamais. Il nous suffit de sortir cette constante hors de mon composant, pour que l'Objet ne soit pas redéfini à chaque exécution, et garde donc toujours la même référence
import React, { useState, memo } from 'react';
const STYLE = { backgroundColor: 'red', color: 'white' };
const App = () => {
const [state, setState] = useState({
value: 0,
name: 'John',
});
const onClick = () => {
setState(state => ({
...state,
value: state.value + 1,
}));
};
const onClickChangeName = () => {
setState(state => ({
...state,
name: state.name === 'Doe' ? 'John' : 'Doe',
}));
};
return (
<>
<button onClick={onClick}>Count is {state.value}</button>
<button onClick={onClickChangeName}>
Change Name to {state.name === 'John' ? 'Doe' : 'John'}
</button>
<ChildrenComponent name={state.name} style={STYLE} />
</>
);
};
const ChildrenComponent = memo(({ name, style }) => {
console.log('Child Rendered!');
return <div style={style}>Your name is {name}</div>;
});
export default App;
Objets "dynamique"
Evidemment cette solution ne fonctionne pas si on doit générer dynamiquement une des valeurs de l'object.
React nous offre un "hook de mémorisation" useMemo
useMemo
useMemo
est un hook au fonctionnement à mi chemin entre memo
et useEffect
. useMemo
est un hook qui prend 2 paramètres : une fonction et un tableau de dépendance
const App = () => {
const memoized = useMemo(() => {
...
return something // Ce retour sera mémorisé tant qu'aucun changement n'intervient dans le tableau de dépendance
},
[] // Tableau de dépendance
)
}
La fonction en 1er paramètre sera executée au 1er render, et son retour mis en mémoire. Aux prochains renders du composant, la fonction ne sera pas ré-executée, mais retournera directement la valeur en mémoire. Si une des valeurs dans son tableau de dépendance change, la fonction sera de nouveau executée, et son retour mis en mémoire.
Exemple
import React, { useState, useMemo, memo } from 'react';
const App = () => {
const [state, setState] = useState({
value: 0,
name: 'John',
});
const onClick = () => {
setState(state => ({
...state,
value: state.value + 1,
}));
};
const onClickChangeName = () => {
setState(state => ({
...state,
name: state.name === 'Doe' ? 'John' : 'Doe',
}));
};
const isRed = state.value < 5 || state.value > 10;
const style = useMemo(() => {
const color = '#fff';
const backgroundColor = isRed ? 'red' : 'green';
return { color, backgroundColor };
}, [isRed]);
return (
<>
<button onClick={onClick}>Count is {state.value}</button>
<button onClick={onClickChangeName}>
Change Name to {state.name === 'John' ? 'Doe' : 'John'}
</button>
<ChildrenComponent name={state.name} style={style} />
</>
);
};
const ChildrenComponent = memo(({ name, style }) => {
console.log('Child Rendered!');
return <div style={style}>Your name is {name}</div>;
});
export default App;
Ici dans le tableau de dépendance, nous avons la variable isRed
qui est un Boolean
, donc qui peut etre soit true
ou false
. Tant que le valeur de ce booléen ne change pas, la fonction mémorisée par useMemo
n'est pas ré-executée, et renvoie donc la même référence de l'objet style
. Quand ce booléen change, la fonction est re-executée et la référence de style
change, ce qui provoque bien le render de ChildComponent
Vous pouvez ajouter autant de dépendances que vous désirez dans le tableau de dépendance de useMemo
. Si une seule de ces valeurs change, la fonction sera re-executée
const memoized = useMemo(() => ..., [value1, value2, value3, ...])
Si votre tableau de dépendance est vide, la fonction ne sera jamais re-executée et renverra toujours la valeur mémorisée. Attention il faut bien metre un tableau de dépendance vide, si vous omettez le tableau de dépendance, la fonction sera re-executée à chaque render
React effectue des comparaisons strictes sur les éléments du tableau de dépendance de useMemo
! Il est donc très fortement déconseillé de passer autre chose que des primitives (Number, String, Boolean) dans les tableaux de dépendance des hooks.
Cette règle est la même pour useEffect
et useCallback
(qu'on verra plus tard)
Et pour les fonctions ?
On a vu que useMemo
permettait de retourner une valeur mémorisée. On pourrait donc utiliser ce hook pour retourner notre fonction de handle
import React, { useState, useMemo, memo } from 'react';
const App = () => {
const [state, setState] = useState({
value: 0,
});
const onClick = useMemo(() => {
return inc => {
setState(state => ({
...state,
value: state.value + inc,
}));
};
}, []);
return (
<>
Count value is {state.value} <br />
<ButtonInc value={1} onClick={onClick} />
<ButtonInc value={5} onClick={onClick} />
<ButtonInc value={-10} onClick={onClick} />
</>
);
};
const ButtonInc = memo(({ value, onClick }) => {
console.log(`Render button ${value}`);
return <button onClick={() => onClick(value)}>Inc by {value}</button>;
});
export default App;
React nous propose un moyen d'écrire plus rapidement ce hook, sans avoir à retourner une fonction, le hook de mémorisation useCallback
useCallback
De la même manière que useMemo
, useCallback
est un hook qui prend 2 paramètres, une fonction et un tableau de dépendance. Mais là où useMemo
mémorise le retour de la fonction, useCallback
mémorise la fonction elle-même.
Concrètement, la fonction que vous faites passer en 1er paramètre sera mémorisée tant que le tableau de dépendance ne change pas. Le code précédent peut donc s'écrire :
import React, { useState, useCallback, memo } from 'react';
const App = () => {
const [state, setState] = useState({
value: 0,
});
const onClick = useCallback(inc => {
setState(state => ({
...state,
value: state.value + inc,
}));
}, []);
return (
<>
Count value is {state.value} <br />
<ButtonInc value={1} onClick={onClick} />
<ButtonInc value={5} onClick={onClick} />
<ButtonInc value={-10} onClick={onClick} />
</>
);
};
const ButtonInc = memo(({ value, onClick }) => {
console.log(`Render button ${value}`);
return <button onClick={() => onClick(value)}>Inc by {value}</button>;
});
export default App;
En bref
Utilisez memo
, useMemo
et useCallback
pour éviter les re-render inutiles
Les objets statiques qui ne changent pas, doivent être déclarés hors des composants fonction
Evitez au maximum les non primitives dans les props et tableaux de dépendance des hooks
Exercices
Tous les codes suivant présentent une erreur de conception. Essayez de les retrouver ! Cliquez ensuite sur l'onglet "Solution" de chaques sections pour avoir la solution !
Exercice 1
- Code
- Solution
import React, { useState } from 'react';
const ChildComponent = ({ name }) => {
return <div>Hello there, I'm {name}!</div>;
};
const App = () => {
const [value, setValue] = useState(0);
const onClick = () => setValue(v => v + 1);
return (
<>
<ChildComponent name="John" />
<div>
And I'm the counter you've clicked {value} times!{' '}
<button onClick={onClick}>Inc me!</button>
</div>
</>
);
};
export default App;
import React, { useState } from 'react';
const ChildComponent = ({ name }) => {
return <div>Hello there, I'm {name}!</div>;
};
const App = () => {
const [value, setValue] = useState(0);
const onClick = () => setValue(v => v + 1);
return (
<>
<ChildComponent name="John" />
<div>
And I'm the counter you've clicked {value} times!{' '}
<button onClick={onClick}>Inc me!</button>
</div>
</>
);
};
export default App;
On commence doucement, le composant ChildComponent
n'est pas wrappé par memo
, il n'y a donc aucune mémorisation, et donc une ré-execution systématique du composant fonction
Exercice 2
- Code
- Solution
import React, { memo, useState } from 'react';
const ChildComponent = memo(({ name, style }) => {
return <div style={style}>Hello there, I'm {name}!</div>;
});
const App = () => {
const [value, setValue] = useState(0);
const onClick = () => setValue(v => v + 1);
const style = { backgroundColor: 'red', color: 'white' };
return (
<>
<ChildComponent name="John" style={style} />
<div>
And I'm the counter you've clicked {value} times!{' '}
<button onClick={onClick}>Inc me!</button>
</div>
</>
);
};
export default App;
import React, { memo, useState } from 'react';
const ChildComponent = memo(({ name, style }) => {
return <div style={style}>Hello there, I'm {name}!</div>;
});
const App = () => {
const [value, setValue] = useState(0);
const onClick = () => setValue(v => v + 1);
const style = { backgroundColor: 'red', color: 'white' };
return (
<>
<ChildComponent name="John" style={style} />
<div>
And I'm the counter you've clicked {value} times!{' '}
<button onClick={onClick}>Inc me!</button>
</div>
</>
);
};
export default App;
L'object style
est redefini à chaque execution de App
. Javascript compare les références des Objects. Bien que les objets semblent être identiques de part leurs clés/valeurs, les références sont différentes, et donc ChildComponent
est ré-executé à chaque fois.
Exercice 3
- Code
- Solution
import React, { memo, useState, useMemo } from 'react';
const ChildComponent = memo(({ name, style }) => {
return <div style={style}>Hello there, I'm {name}!</div>;
});
const App = () => {
const [value, setValue] = useState(0);
const onClick = () => setValue(v => v + 1);
const style = useMemo(() => {
return {
backgroundColor: ['red', 'black', 'cyan', 'white'][value % 4],
color: ['white', 'grey', 'red', 'black'][value % 4],
};
}, [value]);
return (
<>
<ChildComponent name="John" style={style} />
<div>
And I'm the counter you've clicked {value} times!{' '}
<button onClick={onClick}>Inc me!</button>
</div>
</>
);
};
export default App;
import React, { memo, useState, useMemo } from 'react';
const ChildComponent = memo(({ name, style }) => {
return <div style={style}>Hello there, I'm {name}!</div>;
});
const App = () => {
const [value, setValue] = useState(0);
const onClick = () => setValue(v => v + 1);
const style = useMemo(() => {
return {
backgroundColor: ['red', 'black', 'cyan', 'white'][value % 4],
color: ['white', 'grey', 'red', 'black'][value % 4],
};
}, [value]);
return (
<>
<ChildComponent name="John" style={style} />
<div>
And I'm the counter you've clicked {value} times!{' '}
<button onClick={onClick}>Inc me!</button>
</div>
</>
);
};
export default App;
Ici, nous construisons un Object style
qui, en fonction de la valeur du compteur, va prendre des valeurs backgroundColor
& color
successives dans un tableau.
Le problème vient du fait que value
, la valeur du compteur, est dans le tableau de dépendance. Cette valeur étant la seul à même de faire un re-render de App
, l'avoir dans le tableau de dépendance va faire que le style
va etre re-calculé à chaque render. Et donc recréer un nouvel objet à chaque render.
Le cas ici est caractéristique d'une volontée d'optimisation, qui non seulement n'est pas efficace (ChildComponent
est re-executé à chaque fois) mais qui en plus va baisser les performances de l'application.
A chaque render de App
, on va faire une comparaison du tableau de dépendance du useMemo
(qui sera toujours différente) ainsi qu'une comparaison des props de ChildComponent
(qui seront aussi toujours différent). On se retrouve donc à faire, à chaque render, 2 comparaisons d'objets dont on sait qu'ils seront différent !
Exercice 4
- Code
- Solution
import React, { memo, useState } from 'react';
const Button = memo(({ value, onClick }) => {
return <button onClick={() => onClick(value)}>{value}</button>;
});
const App = () => {
const [value, setValue] = useState(0);
const onClick = value => setValue(v => v + value);
return (
<>
<div>
Counter value is <b>{value}</b>
</div>
<div>
<Button value={1} onClick={onClick} />
<Button value={5} onClick={onClick} />
<Button value={-10} onClick={onClick} />
</div>
</>
);
};
export default App;
import React, { memo, useState } from 'react';
const Button = memo(({ value, onClick }) => {
return <button onClick={() => onClick(value)}>{value}</button>;
});
const App = () => {
const [value, setValue] = useState(0);
const onClick = value => setValue(v => v + value);
return (
<>
<div>
Counter value is <b>{value}</b>
</div>
<div>
<Button value={1} onClick={onClick} />
<Button value={5} onClick={onClick} />
<Button value={-10} onClick={onClick} />
</div>
</>
);
};
export default App;
A chaque render, notre fonction onClick
va être redéfinie, et donc la comparaison de memo
échouer pour Button
. Donc tous les Button
sont re-executé à chaque clic sur n'importe quel bouton
Exercice 5
- Code
- Solution
import React, { memo, useState } from 'react';
const Button = memo(({ label, onClick }) => {
return <button onClick={onClick}>{label}</button>;
});
const App = () => {
const [value, setValue] = useState(0);
return (
<>
<div>
Counter value is <b>{value}</b>
</div>
<div>
<Button onClick={() => setValue(1)} label="set as 1" />
<Button onClick={() => setValue(5)} label="set as 5" />
<Button onClick={() => setValue(-10)} label="set as -10" />
</div>
</>
);
};
export default App;
import React, { memo, useState } from 'react';
const Button = memo(({ label, onClick }) => {
return <button onClick={onClick}>{label}</button>;
});
const App = () => {
const [value, setValue] = useState(0);
return (
<>
<div>
Counter value is <b>{value}</b>
</div>
<div>
<Button onClick={() => setValue(1)} label="set as 1" />
<Button onClick={() => setValue(5)} label="set as 5" />
<Button onClick={() => setValue(-10)} label="set as -10" />
</div>
</>
);
};
export default App;
De la meme manière que l'exemple précédent, les fonctions onClick
sont ici redefinies à chaque execution, la prop onClick
des Button
change donc à chaque render
Exercice 6
- Code
- Solution
import React, { memo, useState, useCallback } from 'react';
const Button = memo(({ onClick, value, label }) => {
return <button onClick={() => onClick(value)}>{label}</button>;
});
const App = () => {
const [value, setValue] = useState(0);
const onClick = useCallback(value => setValue(v => v + value));
return (
<>
<div>
Counter value is <b>{value}</b>
</div>
<div>
<Button onClick={onClick} value={1} label="Inc by 1" />
<Button onClick={onClick} value={5} label="Inc by 5" />
<Button onClick={onClick} value={-10} label="Dec by 10" />
</div>
</>
);
};
export default App;
import React, { memo, useState, useCallback } from 'react';
const Button = memo(({ onClick, value, label }) => {
return <button onClick={() => onClick(value)}>{label}</button>;
});
const App = () => {
const [value, setValue] = useState(0);
const onClick = useCallback(value => setValue(v => v + value));
return (
<>
<div>
Counter value is <b>{value}</b>
</div>
<div>
<Button onClick={onClick} value={1} label="Inc by 1" />
<Button onClick={onClick} value={5} label="Inc by 5" />
<Button onClick={onClick} value={-10} label="Dec by 10" />
</div>
</>
);
};
export default App;
Il n'y a pas de tableau de dépendance à notre useCallback
! Il ne sert donc absolument à rien, étant redefini à chaque execution 😀
Notez également dans notre composant Button
le :
<button onClick={() => onClick(value)}>
On pourrait etre tenté de se dire (vu l'exemple précédent) que ce n'est pas une bonne syntaxe, et le réécrire comme ci :
const Button = memo(({onClick, value, label}) => {
const _onClick = useCallback(() => onClick(value), [onClick, value]);
return <button onClick={_onClick}>{label}</button>
});
Cette syntaxe serait en effet parfaitement valide. Cependant, comme Button
n'a pas de children, et pas de state (qu'il ne dépend donc que de ces props) Si ce component se re-render suite à des changements de props, on est sur qu'il est le dernier de son arbre, et ne déclenchera pas d'autre re-render en cascade. Il n'est donc pas forcément nécessaire d'utiliser un useCallback
ici.
Notez quand même que la syntaxe avec useCallback
reste parfaitement valide, et permet toujours une petite optimisation car React n'aura même pas à toucher le DOM pour changer l'evenement onClick
sur le bouton.
Exercice 7
- Code
- Solution
import React, { memo, useState, useCallback } from 'react';
const Button = memo(({ onClick, label }) => {
return <button onClick={onClick}>{label}</button>;
});
const App = () => {
const [value, setValue] = useState(0);
const onClick = useCallback(value => () => setValue(v => v + value), []);
return (
<>
<div>
Counter value is <b>{value}</b>
</div>
<div>
<Button onClick={onClick(1)} label="Inc by 1" />
<Button onClick={onClick(5)} label="Inc by 5" />
<Button onClick={onClick(-10)} label="Dec by 10" />
</div>
</>
);
};
export default App;
import React, { memo, useState, useCallback } from 'react';
const Button = memo(({ onClick, label }) => {
return <button onClick={onClick}>{label}</button>;
});
const App = () => {
const [value, setValue] = useState(0);
const onClick = useCallback(value => () => setValue(v => v + value), []);
return (
<>
<div>
Counter value is <b>{value}</b>
</div>
<div>
<Button onClick={onClick(1)} label="Inc by 1" />
<Button onClick={onClick(5)} label="Inc by 5" />
<Button onClick={onClick(-10)} label="Dec by 10" />
</div>
</>
);
};
export default App;
On finit avec un exemple un peu plus complexe !
On pourrait etre tenté de se dire que tout est bon, car la fonction onClick
est bien mémorisée via useCallback
, et comme le tableau de dépendance est vide, la fonction ne sera jamais redefinie, et donc sa référence ne changera jamais. Et vous avez raison 😀
Alors quel est le problème ? Et bien onClick
est certes mémorisé, mais onClick
retourne une fonction. Si on décompose un peu la déclaration de la fonction, on a :
const onClick = useCallback(value => {
return function () {
setValue( v => v + value)
}
})
Et c'est ce retour qui est passé en props à nos composants Button
. Or useCallback
mémorise la fonction et non le retour de la fonction.
Donc à chaque render, nous allons faire passer en prop onClick
à nos Button
, le retour de la fonction onClick(n)
qui est donc lui, une nouvelle fonction à chaque fois.