React Re-render

--- vues
Banner image for React Re-render

C'est l'un des sujets les plus importants aujourd'hui en ce qui concerne le développement d'applications, et par conséquent, c'est un thème central de ce livre.

Quand il s'agit de React et de la performance dans React, il est crucial de comprendre les re-renders et leur influence. Comment ils sont déclenchés, comment ils se propagent à travers l'application, ce qui se passe lorsqu'un component re-render et pourquoi, et pourquoi nous en avons besoin en premier lieu.

Ce chapitre introduit ces concepts, qui seront explorés plus en détail dans les chapitres suivants. Et pour rendre cela plus intéressant, présentons-le sous forme d'une enquête. Nous allons introduire un problème de performance très courant dans une application, examiner ce qui se passe à cause de celui-ci, et comment le résoudre avec une technique de composition très simple. Ce faisant, vous apprendrez :

  • Ce qu'est un re-render et pourquoi nous en avons besoin.
  • Quelle est la source initiale de tous les re-renders.
  • Comment React propage les re-renders à travers l'application.
  • Le grand mythe des re-renders et pourquoi le changement des props seul n'a pas d'importance.
  • La technique de "descente d'état" pour améliorer les performances.
  • Pourquoi les hooks peuvent être dangereux en matière de re-renders.

Le problème#

Imaginez-vous en tant que développeur héritant d'une application complexe, volumineuse et très sensible aux performances. Beaucoup de choses s'y passent, de nombreuses personnes y ont travaillé au fil des années, et des millions de clients l'utilisent actuellement.

Pour votre première tâche, on vous demande d'ajouter un simple bouton qui ouvre une modal dialog en haut de cette application.

Vous examinez le code et trouvez l'endroit où le dialog doit être déclenché :

App.tsx
const App = () => {
    // lots of code here
    return (
        <div className="layout">
            {/* button should go somewhere here */}
            <VerySlowComponent />
            <BunchOfStuff />
            <OtherStuffAlsoComplicated />
        </div>
    );
};

Ensuite, vous l'implémentez. La tâche semble triviale. Nous l'avons tous fait des centaines de fois :

App.tsx
const App = () => {
    // add some state
    const [isOpen, setIsOpen] = useState(false);
    return (
        <div className="layout">
            {/* add the button */}
            <Button onClick={() => setIsOpen(true)}>
            Open dialog
            </Button>
            {/* add the dialog itself */}
            {isOpen ? (
            <ModalDialog onClose={() => setIsOpen(false)} />
            ) : null}
            <VerySlowComponent />
            <BunchOfStuff />
            <OtherStuffAlsoComplicated />
        </div>
    );
};

Il suffit d'ajouter un state qui indique si le dialog est ouvert ou fermé. Ajoutez le bouton qui déclenche la mise à jour du state au clic. Et le dialog lui-même qui est rendu si la variable d'état est true.

Vous démarrez l'application, vous l'essayez - et oups. Il faut presque une seconde pour ouvrir ce simple dialog !

Les personnes expérimentées en matière de performance React pourraient être tentées de dire : "Ah, bien sûr ! Vous re-render toute l'application ici, il suffit d'envelopper tout dans React.memo et d'utiliser des hooks useCallback pour l'empêcher." Et techniquement, c'est vrai. Mais ne nous précipitons pas. La mémorisation est totalement inutile ici et fera plus de mal que de bien. Il existe une méthode plus efficace.

Mais d'abord, examinons exactement ce qui se passe ici et pourquoi.

State update, nested components, and re-renders#

Commençons par le début : la vie de notre component et les étapes les plus importantes dont nous devons nous soucier lorsque nous parlons de performance. Ces étapes sont : le mounting, l'unmounting et le re-rendering.

Lorsqu'un component apparaît pour la première fois à l'écran, on appelle cela le mounting. C'est à ce moment que React crée l'instance de ce component pour la première fois, initialise son state, exécute ses hooks et ajoute les éléments au DOM. Le résultat final - nous voyons ce que nous rendons dans ce component à l'écran.

Ensuite, il y a l'unmounting : c'est lorsque React détecte qu'un component n'est plus nécessaire. Il effectue alors le nettoyage final, détruit l'instance de ce component et tout ce qui lui est associé, comme le state du component, et supprime finalement l'élément DOM qui lui est associé.

Et enfin, le re-rendering. C'est lorsque React met à jour un component déjà existant avec de nouvelles informations. Comparé au mounting, le re-rendering est léger : React réutilise simplement l'instance existante, exécute les hooks, effectue tous les calculs nécessaires et met à jour l'élément DOM existant avec les nouveaux attributs.

Chaque re-render commence avec le state. Dans React, chaque fois que nous utilisons un hook comme useState, useReducer, ou n'importe quelle librairie externe de gestion d'état comme Redux, nous ajoutons de l'interactivité à un component. À partir de ce moment, un component aura une partie de données qui est préservée tout au long de son cycle de vie. Si quelque chose se produit qui nécessite une réponse interactive, comme un utilisateur cliquant sur un bouton ou des données externes arrivant, nous mettons à jour le state avec les nouvelles données.

Le re-rendering est l'une des choses les plus importantes à comprendre dans React. C'est à ce moment que React met à jour le component avec les nouvelles données et déclenche tous les hooks qui dépendent de ces données. Sans cela, il n'y aurait pas de mises à jour de données dans React et, par conséquent, pas d'interactivité. L'application serait complètement statique. Et la mise à jour du state est la source initiale de tous les re-renders dans les applications React. Si nous prenons notre application initiale comme exemple :

App.tsx
const App = () => {
    const [isOpen, setIsOpen] = useState(false);
    return (
        <div className="layout">
            <Button onClick={() => setIsOpen(true)}>
                Open dialog
            </Button>
        </div>
    );
};

Lorsque nous cliquons sur le Button, nous déclenchons la fonction setter setIsOpen : nous mettons à jour le state isOpen avec la nouvelle valeur de false à true. En conséquence, le component App qui contient ce state se re-render.

Après que le state soit mis à jour et que le component App se re-render, les nouvelles données doivent être transmises aux autres components qui en dépendent. React fait cela automatiquement pour nous : il prend tous les components que le component initial rend à l'intérieur, les re-render, puis re-render les components imbriqués à l'intérieur de ceux-ci, et ainsi de suite jusqu'à ce qu'il atteigne la fin de la chaîne des components.

Si vous imaginez une application React typique comme un arbre, tout ce qui se trouve en dessous de l'endroit où la mise à jour du state a été initiée sera re-render.

state update

Dans le cas de notre application, tout ce qu'elle rend, tous ces components très lents, seront re-rendus lorsque le state change :

App.tsx
const App = () => {
    const [isOpen, setIsOpen] = useState(false);
    // everything that is returned here will be re-rendered when the
    // state is updated
    return (
        <div className="layout">
            <Button onClick={() => setIsOpen(true)}>
                Open dialog
            </Button>
            {isOpen ? (
                <ModalDialog onClose={() => setIsOpen(false)} />
            ) : null}
            <VerySlowComponent />
            <BunchOfStuff />
            <OtherStuffAlsoComplicated />
        </div>
    );
};

En conséquence, il faut presque une seconde pour ouvrir la boîte de dialogue - React doit tout re-render avant que la boîte de dialogue puisse apparaître à l'écran.

L'élément important à retenir ici est que React ne remonte jamais "vers le haut" de l'arbre de render lorsqu'il re-render les components. Si une mise à jour de state est initiée quelque part au milieu de l'arbre des components, seuls les components "en dessous" de l'arbre seront re-rendus.

state update

La seule façon pour les components situés au "bas" d'affecter les components situés au "sommet" de la hiérarchie est soit d'appeler explicitement une mise à jour du state dans les components du "sommet", soit de passer des components en tant que fonctions.

Le grand mythe des re-renders#

Avez-vous remarqué que je n'ai rien mentionné à propos des props jusqu'ici ? Vous avez peut-être entendu cette affirmation : "Un component se re-render lorsque ses props changent." C'est l'une des idées fausses les plus répandues dans React : tout le monde y croit, personne ne la remet en question, et pourtant ce n'est tout simplement pas vrai.

Le comportement normal de React est que si une mise à jour de state est déclenchée, React va re-render tous les components imbriqués indépendamment de leurs props. Et si aucune mise à jour de state n'est déclenchée, alors le changement des props sera simplement "avalé" : React ne les surveille pas.

Si j'ai un component avec des props, et que j'essaie de changer ces props sans déclencher une mise à jour de state, quelque chose comme ceci :

App.tsx
const App = () => {
    // local variable won't work
    let isOpen = false;
    return (
        <div className="layout">
            {/* nothing will happen */}
            <Button onClick={() => (isOpen = true)}>
            Open dialog
            </Button>
            {/* will never show up */}
            {isOpen ? (
            <ModalDialog onClose={() => (isOpen = false)} />
            ) : null}
        </div>
    );
};

Cela ne fonctionnera tout simplement pas. Lorsque le Button est cliqué, la variable locale isOpen changera. Mais le cycle de vie React n'est pas déclenché, donc le résultat du render n'est jamais mis à jour, et le ModalDialog n'apparaîtra jamais.

Dans le contexte des re-renders, le fait que les props aient changé ou non sur un component n'a d'importance que dans un seul cas : si ledit component est enveloppé dans le higher-order component React.memo. C'est alors, et seulement alors, que React arrêtera sa chaîne naturelle de re-renders et vérifiera d'abord les props. Si aucune des props ne change, alors les re-renders s'arrêteront là. Si une seule prop change, ils continueront comme d'habitude.

react memo

Empêcher les re-renders avec la mémorisation de manière appropriée est un sujet complexe avec plusieurs mises en garde. Lisez-en plus en détail dans le Chapitre 5 : "Mémorisation avec useMemo, useCallback et React.memo".

Passer state vers le bas#

Maintenant que nous avons clairement compris comment React re-render les components, il est temps d'appliquer cette connaissance au problème initial et de le résoudre. Examinons de plus près le code, en particulier l'endroit où nous utilisons le state du modal dialog :

App.tsx
const App = () => {
 // our state is declared here
 const [isOpen, setIsOpen] = useState(false);
 return (
 <div className="layout">
 {/* state is used here */}
 <Button onClick={() => setIsOpen(true)}>
 Open dialog
 </Button>
 {/* state is used here */}
 {isOpen ? (
 <ModalDialog onClose={() => setIsOpen(false)} />
 ) : null}
 <VerySlowComponent />
 <BunchOfStuff />
 <OtherStuffAlsoComplicated />
 </div>
 );
};

Comme vous pouvez le voir, c'est relativement isolé : nous l'utilisons uniquement sur le component Button et dans le ModalDialog lui-même. Le reste du code, tous ces components très lents, ne dépend pas de ce state et n'a donc pas réellement besoin de se re-render lorsque ce state change. C'est un exemple classique de ce qu'on appelle un re-render inutile.

Les envelopper dans React.memo les empêchera de se re-render dans ce cas, c'est vrai. Mais React.memo comporte de nombreuses mises en garde et complexités (voir plus en détail dans le Chapitre 5 : "Mémorisation avec useMemo, useCallback et React.memo"). Il existe une meilleure solution. Tout ce que nous devons faire est d'extraire les components qui dépendent de ce state et le state lui-même dans un component plus petit :

App.tsx
const ButtonWithModalDialog = () => {
 const [isOpen, setIsOpen] = useState(false);
 // render only Button and ModalDialog here
 return (
 <>
 <Button onClick={() => setIsOpen(true)}>
 Open dialog
 </Button>
 {isOpen ? (
 <ModalDialog onClose={() => setIsOpen(false)} />
 ) : null}
 </>
 );
};

Comme vous pouvez le voir, c'est relativement isolé : nous l'utilisons uniquement sur le component Button et dans le ModalDialog lui-même. Le reste du code, tous ces components très lents, ne dépendent pas de ce state et n'ont donc pas réellement besoin de se re-render lorsque ce state change. C'est un exemple classique de ce qu'on appelle un re-render inutile.

Les envelopper dans React.memo les empêchera de se re-render dans ce cas, c'est vrai. Mais React.memo comporte de nombreuses mises en garde et complexités (voir plus en détail dans le Chapitre 5 : "Mémorisation avec useMemo, useCallback et React.memo"). Il existe une meilleure solution. Tout ce que nous devons faire est d'extraire les components qui dépendent de ce state et le state lui-même dans un component plus petit :

App.tsx
const ButtonWithModalDialog = () => {
 const [isOpen, setIsOpen] = useState(false);
 // render only Button and ModalDialog here
 return (
 <>
 <Button onClick={() => setIsOpen(true)}>
 Open dialog
 </Button>
 {isOpen ? (
 <ModalDialog onClose={() => setIsOpen(false)} />
 ) : null}
 </>
 );
};

Et ensuite, il suffit de render ce nouveau component dans l'App d'origine :

App.tsx
const App = () => {
 return (
 <div className="layout">
 {/* here it goes, component with the state inside */}
 <ButtonWithModalDialog />
 <VerySlowComponent />
 <BunchOfStuff />
 <OtherStuffAlsoComplicated />
 </div>
 );
};

Maintenant, la mise à jour du state lorsque le Button est cliqué est toujours déclenchée, et certains components se re-render à cause de cela. Mais ! Cela ne se produit qu'avec les components à l'intérieur du component ButtonWithModalDialog. Et ce n'est qu'un petit bouton et le dialog qui devaient être rendus de toute façon. Le reste de l'application est protégé.

En substance, nous venons de créer une nouvelle sous-branche dans notre arbre de render et déplacé notre state vers le bas dans celle-ci.

state update

En conséquence, la boîte de dialogue apparaît instantanément. Nous venons de résoudre un problème majeur de performance avec une simple technique de composition !

Le danger des custom hooks#

Un autre concept très important que nous ne devons pas oublier lorsque nous traitons du state, des re-renders et de la performance, ce sont les custom hooks. Après tout, ils ont été introduits précisément pour que nous puissions abstraire la logique liée au state. Il est très courant de voir une logique comme celle que nous avions ci-dessus extraite dans quelque chose comme le hook useModalDialog. Une version simplifiée pourrait ressembler à ceci :

useModalDialog.ts
const useModalDialog = () => {
 const [isOpen, setIsOpen] = useState(false);
 return {
 isOpen,
 open: () => setIsOpen(true),
 close: () => setIsOpen(false),
 };
};

Et ensuite utiliser ce hook dans notre App au lieu de définir le state directement :

App.tsx
const App = () => {
 // state is in the hook now
 const { isOpen, open, close } = useModalDialog();
 return (
 <div className="layout">
 {/* just use "open" method from the hook */}
 <Button onClick={open}>Open dialog</Button>
 {/* just use "close" method from the hook */}
 {isOpen ? <ModalDialog onClose={close} /> : null}
 <VerySlowComponent />
 <BunchOfStuff />
 <OtherStuffAlsoComplicated />
 </div>
 );

Pourquoi ai-je appelé cela "le danger" ? Cela semble être un modèle raisonnable, et le code est légèrement plus propre. Parce que le hook cache le fait que nous avons du state dans l'application. Mais le state est toujours là ! Chaque fois qu'il change, il déclenchera toujours un re-render du component qui utilise ce hook. Peu importe même si ce state est utilisé directement dans App ou même si le hook retourne quelque chose.

Si, par exemple, je veux être sophistiqué avec le positionnement de cette boîte de dialogue et introduire du state à l'intérieur de ce hook qui écoute le redimensionnement de la fenêtre :

useModalDialog.ts
const useModalDialog = () => {
 const [width, setWidth] = useState(0);
 useEffect(() => {
 const listener = () => {
 setWidth(window.innerWidth);
 }
 window.addEventListener('resize', listener);
 return () => window.removeEventListener('resize', listener);
 }, []);
 // return is the same
 return ...
}

Le component App entier se re-render à chaque redimensionnement, même si cette valeur n'est même pas retournée par le hook !

Les hooks sont essentiellement comme des poches dans votre pantalon. Si, au lieu de porter un haltère de 10 kilogrammes dans vos mains, vous le mettez dans votre poche, cela ne changerait pas le fait qu'il est toujours difficile de courir : vous avez 10 kilogrammes de poids supplémentaire sur vous. Mais si vous mettez ces dix kilogrammes dans un chariot autonome, vous pouvez courir librement et fraîchement et peut-être même vous arrêter pour prendre un café : le chariot s'occupera de lui-même. Les components pour le state sont ce chariot.

Exactement la même logique s'applique aux hooks qui utilisent d'autres hooks : tout ce qui peut déclencher un re-render, aussi profond soit-il dans la chaîne des hooks, déclenchera un re-render dans le component qui utilise ce tout premier hook. Si j'extrais ce state supplémentaire dans un hook qui retourne null, App se re-render toujours à chaque redimensionnement :

useModalDialog.ts
const useResizeDetector = () => {
 const [width, setWidth] = useState(0);
 useEffect(() => {
 const listener = () => {
 setWidth(window.innerWidth);
 };
 window.addEventListener('resize', listener);
 return () => window.removeEventListener('resize', listener);
 }, []);
 return null;
}
const useModalDialog = () => {
 // I don't even use it, just call it here
 useResizeDetector();
 // return is the same
 return ...
}
const App = () => {
 // this hook uses useResizeDetector underneath that triggers
state update on resize
 // the entire App will re-render on every resize!
 const { isOpen, open, close } = useModalDialog();
 return // same return
}

Alors, soyez prudent avec ceux-ci. Pour corriger notre application, vous devrez toujours extraire ce bouton, cette boîte de dialogue et le hook personnalisé dans un component :

App.tsx
const ButtonWithModalDialog = () => {
 const { isOpen, open, close } = useModalDialog();
 // render only Button and ModalDialog here
 return (
 <>
 <Button onClick={open}>Open dialog</Button>
 {isOpen ? <ModalDialog onClose={close} /> : null}
 </>
 );
};

Donc, l'emplacement où vous placez le state est très important. Idéalement, pour éviter les futurs problèmes de performance, vous voudriez l'isoler autant que possible dans des components aussi petits et légers que possible. Dans le prochain chapitre (Chapitre 2. Éléments, enfants en tant que props et re-renders), nous examinerons un autre pattern qui aide exactement avec cela.

Points clés à retenir#

Ce n'est que le début. Dans les chapitres suivants, nous approfondirons plus en détail le fonctionnement de tout cela. En attendant, voici les points essentiels à retenir de ce chapitre :

  • Le re-rendering est la façon dont React met à jour les components avec de nouvelles données.
  • Sans re-renders, il n'y aura pas d'interactivité dans nos applications.
  • La mise à jour du state est la source initiale de tous les re-renders.
  • Si le re-render d'un component est déclenché, tous les components imbriqués à l'intérieur de ce component seront re-rendus.
  • Pendant le cycle normal des re-renders de React (sans utilisation de mémorisation), le changement des props n'a pas d'importance : les components se re-renderont même s'ils n'ont pas de props.
  • Nous pouvons utiliser le pattern connu sous le nom de "descente du state" pour éviter les re-renders inutiles dans les grandes applications.
  • La mise à jour du state dans un hook déclenchera le re-render d'un component qui utilise ce hook, même si le state lui-même n'est pas utilisé.
  • Dans le cas des hooks utilisant d'autres hooks, toute mise à jour du state dans cette chaîne de hooks déclenchera le re-render d'un component qui utilise le tout premier hook.