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 :
- JavaScript
- TypeScript
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} />
};
import type { Dispatch, SetStateAction } from 'react';
import { useState } from 'react';
type Theme = 'light' | 'dark';
const App = () => {
const [theme, setTheme] = useState<Theme>('light');
return <A theme={theme} setTheme={theme} />
};
const A = ({ theme, setTheme }: { theme: Theme, setTheme: Dispatch<SetStateAction<Theme>> }) => {
return <B theme={theme} setTheme={setTheme} />
};
const B = ({ theme, setTheme }: { theme: Theme, setTheme: Dispatch<SetStateAction<Theme>> }) => {
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) :
- JavaScript
- TypeScript
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: () => {},
});
import type { Dispatch, SetStateAction } from 'react';
import { createContext } from 'react';
// On crée un type pour les valeurs de thème
export type Theme = 'light' | 'dark';
// On crée un type pour notre contexte
type ThemeContextType = {
theme: Theme;
setTheme: Dispatch<SetStateAction<Theme>>;
};
// On crée notre contexte, avec une valeur par défaut : un thème clair
const ThemeContext = createContext<ThemeContextType>({
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
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.
- JavaScript
- TypeScript
import { useState } from 'react';
const App = () => {
const [theme, setTheme] = useState('light');
return (
<ThemeContext.Provider value={{ theme, setTheme }}>
<A />
</ThemeContext.Provider>
);
};
import type { Theme } from './ThemeContext';
import { useState } from 'react';
const App = () => {
const [theme, setTheme] = useState<Theme>('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 :
- JavaScript
- TypeScript
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 };
import type { ReactNode } from 'react';
import { createContext, useState } from 'react';
export type Theme = 'light' | 'dark';
type ThemeContextType = {
theme: Theme;
setTheme: Dispatch<SetStateAction<Theme>>;
};
const ThemeContext = createContext<ThemeContextType>({
theme: 'light',
setTheme: () => {},
});
type ThemeProviderProps = {
children: ReactNode;
};
const ThemeProvider = ({ children }: ThemeProviderProps) => {
const [theme, setTheme] = useState<Theme>('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
:
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.
import { useContext } from 'react';
Ensuite, on pensera à importer le contexte que l'on souhaite utiliser :
import { ThemeContext } from './ThemeContext';
Et enfin, on va pouvoir utiliser le hook useContext
pour récupérer les données du contexte :
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.
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 ?
- JavaScript
- TypeScript
const Providers = ({ providers, children }) => {
return (
<>
{/** Ouverture des providers */}
{children}
{/** Fermeture des providers */}
</>
);
};
import type { ReactNode } from 'react';
type ProvidersProps = {
providers: ReactNode[];
children: ReactNode;
};
const Providers = ({ providers, children }: ProvidersProps) => {
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.
- JavaScript
- TypeScript
const nest = (children, component) => {
return React.cloneElement(component, {}, children);
};
const nest = (children: ReactNode, component: ReactNode) => {
return React.cloneElement(component, {}, children);
};
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
:
- JavaScript
- TypeScript
const nest = (children, component) => {
return React.cloneElement(component, {}, children);
};
const Providers = ({ providers, children }) => {
return providers.reduceRight(nest, children);
};
import type { ReactNode } from 'react';
type ProvidersProps = {
providers: ReactNode[];
children: ReactNode;
};
const nest = (children: ReactNode, component: ReactNode) => {
return React.cloneElement(component, {}, children);
};
const Providers = ({ providers, children }: ProvidersProps) => {
return providers.reduceRight(nest, children);
};
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
:
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.