Aller au contenu principal

L'utilisation des contextes avec React

Introduction

Les contextes sont un moyen de diffuser des données au travers des composants, sans avoir à les passer explicitement à chaque composant.
Pour faire simple, imaginons une arborescence de plusieurs composants imbriqués les uns dans les autres :

App.jsx
import { useState } from 'react';

const App = () => {
const [theme, setTheme] = useState('light');

return <A theme={theme} setTheme={theme} />
};

const A = ({ theme, setTheme }) => {
return <B theme={theme} setTheme={setTheme} />
};

const B = ({ theme, setTheme }) => {
return <C theme={theme} setTheme={setTheme} />
};

Fastidieux, n'est-ce pas ? On transmet à chaque fois les mêmes données, et ce, à chaque niveau de l'arborescence.

C'est là que les contextes entrent en jeu !
On va pouvoir alors déclarer notre contexte (qui contiendra les données à diffuser) et le fournir à un niveau supérieur de l'arborescence.

Déclaration d'un contexte

Avant de penser à notre contexte, on va réfléchir à ce que l'on veut diffuser et les valeurs par défaut.
Si on reprend notre exemple avec le thème clair et sombre, on sait que l'on va vouloir diffuser la valeur du thème et une fonction pour le changer.

On va donc préparer le terrain en créant un fichier ThemeContext.jsx (ou ThemeContext.tsx si tu utilises TypeScript) :

ThemeContext.jsx
import { createContext } from 'react';

// On crée notre contexte, avec une valeur par défaut : un thème clair
const ThemeContext = createContext({
theme: 'light',
setTheme: () => {},
});

Fournir un contexte

Maintenant on peut le dire : notre contexte est prêt à être utilisé !
Il ne reste plus qu'à le fournir à notre arborescence de composants en lui créant un Provider.

Un Provider ?

Un Provider est un composant qui va permettre de diffuser les données du contexte à ses enfants.
Il est important de noter que le Provider doit être placé au-dessus des composants qui vont utiliser le contexte.

Un contexte React est un objet qui contient deux propriétés : Provider et Consumer.
Le Provider est un composant qui va permettre de diffuser les données du contexte à ses enfants.
Le Consumer est un composant qui va permettre de récupérer les données du contexte.

App.jsx
import { useState } from 'react';

const App = () => {
const [theme, setTheme] = useState('light');

return (
<ThemeContext.Provider value={{ theme, setTheme }}>
<A />
</ThemeContext.Provider>
);
};

Mais on peut aller encore plus loin, en créant un Provider dédié à notre contexte !
Cela permettra de simplifier l'arborescence de composants et de rendre le code plus lisible :

ThemeContext.jsx
import { createContext, useState } from 'react';

const ThemeContext = createContext({
theme: 'light',
setTheme: () => {},
});

const ThemeProvider = ({ children }) => {
const [theme, setTheme] = useState('light');

return (
<ThemeContext.Provider value={{ theme, setTheme }}>
{children}
</ThemeContext.Provider>
);
};

export { ThemeContext, ThemeProvider };

Et pour terminer, on va maintenant pouvoir directement imbriquer notre ThemeProvider dans notre App :

App.jsx (ou App.tsx)
import { ThemeProvider } from './ThemeContext';

const App = () => {
return (
<ThemeProvider>
<A />
</ThemeProvider>
);
};

Utilisation d'un contexte

C'est bien beau de créer un contexte, mais comment l'utiliser ?
Tu te souviens peut-être du Consumer que l'on a évoqué plus tôt, non ?

Et bien, il est temps de le mettre en pratique ! 😁

Pour commencer, nous allons avoir besoin du fameux hook useContext de React.
Ce hook va nous permettre de récupérer les données du contexte, et ce, directement dans nos composants.

C.jsx (ou C.tsx)
import { useContext } from 'react';

Ensuite, on pensera à importer le contexte que l'on souhaite utiliser :

C.jsx (ou C.tsx)
import { ThemeContext } from './ThemeContext';

Et enfin, on va pouvoir utiliser le hook useContext pour récupérer les données du contexte :

C.jsx (ou C.tsx)
const C = () => {
const { theme, setTheme } = useContext(ThemeContext);

return (
<>
{/** JSX */}
</>
);
};

Pas mal, non ? 😉
Fini l'arborescence de composants à rallonge, on peut maintenant récupérer les données du contexte directement dans nos composants !

Les défauts des contextes

Seulement... Un grand pouvoir implique de grandes responsabilités. 🕷️

Bien que les contextes soient très pratiques, il faut prendre en compte quelques points :

  • On ne peut pas utiliser les contextes pour tout et n'importe quoi.
    Ils sont plutôt adaptés pour diffuser des données qui sont utilisées par plusieurs composants.
  • Les contextes peuvent rendre le code plus difficile à comprendre.
  • L'utilisation de nombreux contextes va faire apparaître ce qu'on appelle le context hell.

Le context hell

Dans cet article, nous avons vu comment créer un contexte et l'utiliser.
Et par chance, nous n'avons pas encore rencontré le context hell.

Mais maintenant, que se passe-t-il si on a besoin de plusieurs contextes (plusieurs dizaines par exemple !) dans notre application ?
On va se retrouver avec une arborescence de composants qui va devenir de plus en plus difficile à comprendre et à maintenir.

Et c'est ça, le context hell.

Exemple de context hell
index.jsx (ou index.tsx)
root.render(
<StrictMode>
<UserProvider>
<ThemeProvider>
<LanguageProvider>
<PostProvider>
<SettingsProvider>
<SocketProvider>
<FriendProvider>
<NotificationProvider>
<ChatProvider>
<MusicProvider>
<VideoProvider>
<GameProvider>
<WeatherProvider>
<NewsProvider>
<CalendarProvider>
<TaskProvider>
<NoteProvider>
<App />
</NoteProvider>
</TaskProvider>
</CalendarProvider>
</NewsProvider>
</WeatherProvider>
</GameProvider>
</VideoProvider>
</MusicProvider>
</ChatProvider>
</NotificationProvider>
</FriendProvider>
</SocketProvider>
</SettingsProvider>
</PostProvider>
</LanguageProvider>
</ThemeProvider>
</UserProvider>
</StrictMode>
);

Maintenant, demande à un développeur d'inverser le provider UserProvider avec le provider NoteProvider.
C'est jouable sans difficulté, mais si tu entends des cris de désespoir, c'est normal. 😅

Pour éviter de tomber dans le context hell, il est important de bien réfléchir à l'utilisation des contextes dans notre application avec ces quelques questions :

  • Est-ce que l'utilisation d'un contexte est vraiment nécessaire pour ce cas d'usage ?
  • Est-ce que le contexte est utilisé par plusieurs composants ?
  • Est-ce que le contexte est utilisé par des composants éloignés dans l'arborescence ?

Mais alors, si tu as besoin d'autant de contextes dans ton application, comment faire ?
Et bien, il existe des solutions pour éviter le context hell :

  • Utiliser des bibliothèques tierces comme Redux (solution lourde, mais très puissante)
  • Créer un nouveau composant qui va regrouper tous les contextes (solution plus légère, mais plus difficile à maintenir)

N'étant pas un grand fan de Redux, je te conseille plutôt la deuxième solution.
Mais si tu veux en savoir plus sur Redux, n'hésite pas à consulter la documentation officielle !

Résoudre le context hell avec un composant dédié

Parlons de ce fameux composant qui va regrouper tous les contextes !
On ne parle pas ici d'un simple composant Providers qui va imbriquer tous les Provider de nos contextes, mais d'une solution plus élégante.

Après tout, nous sommes des feignants développeurs, non ? 😏

Réfléchissons à ce que l'on veut faire :

  • On veut pouvoir regrouper tous les contextes dans un seul composant.
  • On veut pouvoir ajouter ou supprimer des contextes facilement.
  • On veut pouvoir facilement les ordonner entre eux.
  • On veut éviter le context hell.

Et si on créait un composant Providers qui va nous permettre de faire tout ça ?

Providers.jsx
const Providers = ({ providers, children }) => {
return (
<>
{/** Ouverture des providers */}
{children}
{/** Fermeture des providers */}
</>
);
};

Ici on ne va pas remettre une cascade de Provider comme on a pu le voir plus tôt.
On va chercher à créer une fonction qui va nous permettre de les imbriquer les uns dans les autres.

const nest = (children, component) => {
return React.cloneElement(component, {}, children);
};
React.cloneElement

React.cloneElement est une fonction qui va permettre de cloner un élément React en lui passant de nouvelles propriétés.
Cela va nous permettre de créer une nouvelle arborescence de composants sans modifier l'arborescence actuelle.

Le premier argument est l'élément à cloner (le composant), et le deuxième argument est un objet contenant les nouvelles propriétés.
Le troisième argument est le contenu de l'élément cloné (les enfants).

Et maintenant, on va pouvoir utiliser notre fonction nest pour imbriquer nos Provider en utilisant la méthode reduceRight :

Providers.jsx
const nest = (children, component) => {
return React.cloneElement(component, {}, children);
};

const Providers = ({ providers, children }) => {
return providers.reduceRight(nest, children);
};
reduceRight

reduceRight est une méthode qui va permettre de réduire un tableau (ou un objet) en appliquant une fonction de rappel de droite à gauche.
Cela va nous permettre de réduire un tableau de Provider en les imbriquant les uns dans les autres sans se soucier de l'ordre (qui est défini par le tableau).

Dans l'idée, on commence par le dernier élément du tableau, et on l'imbrique avec l'élément précédent du tableau et ainsi de suite jusqu'au premier élément du tableau.
Chaque itération va créer un nouvel élément imbriqué dans le précédent, en appelant la fonction nest qui est passée en argument.

Plus d'informations sur reduceRight

Ajoutons des logs dans notre fonction nest pour mieux comprendre le fonctionnement de reduceRight :

const providers = [<Provider1 />, <Provider2 />, <Provider3 />, <Provider4 />, <Provider5 />];

const nest = (children, component) => {
console.log(`Composant à imbriquer : ${children.type.name}. Composant recevant l'imbriquation : ${component.type.name}.`);
return React.cloneElement(component, {}, children);
};

const Providers = ({ children }) => {
return providers.reduceRight(nest, children);
};

root.render(
<Providers>
<App />
</Providers>
);

Voici les logs qui vont s'afficher :

Composant à imbriquer : App. Composant recevant l'imbriquation : Provider5.
Composant à imbriquer : Provider5. Composant recevant l'imbriquation : Provider4.
Composant à imbriquer : Provider4. Composant recevant l'imbriquation : Provider3.
Composant à imbriquer : Provider3. Composant recevant l'imbriquation : Provider2.
Composant à imbriquer : Provider2. Composant recevant l'imbriquation : Provider1.

Le premier Provider du tableau (<Provider1 />) sera donc le Provider le plus "haut" dans l'arborescence, tandis que le dernier _(<Provider5 />) sera le plus "bas".

Nous obtiendrons donc cette arborescence de composants :

<Provider1>
<Provider2>
<Provider3>
<Provider4>
<Provider5>
<App />
</Provider5>
</Provider4>
</Provider3>
</Provider2>
</Provider1>

Pour mieux comprendre le fonctionnement de reduceRight, n'hésite pas à consulter la documentation officielle.

Et voilà ! Il ne nous reste plus qu'à utiliser notre composant Providers pour regrouper tous nos Provider :

index.jsx (ou index.tsx)
root.render(
<StrictMode>
<Providers
providers={[
<UserProvider />,
<ThemeProvider />,
<LanguageProvider />,
<PostProvider />,
<SettingsProvider />,
<SocketProvider />,
<FriendProvider />,
// ...
]}
>
<App />
</Providers>
</StrictMode>
);

Évidemment le fichier contiendra toujours beaucoup de lignes, mais au moins, on a évité le context hell !
Il sera nettement plus facile de modifier l'ordre des Provider ou d'en ajouter de nouveaux.

Conclusion

Ça casse un peu la tête, mais les contextes sont un outil très puissant pour diffuser des données dans nos applications React.

C'est aussi une excellente solution pour éviter d'utiliser des bibliothèques tierces comme Redux (qui est très bien, mais qui peut être un peu lourd pour des petites applications).

Et si tu as besoin de plusieurs contextes dans ton application, n'oublie pas de réfléchir à l'utilisation de notre composant Providers pour éviter le context hell.