This Is Tuple Context Pattern
5 avril 2025 | 8 mins de lecturePublié sur dev.to et medium.com
Hello There 👋
Je suis de retour avec un nouvel article destiné aux développeurs React, Preact, et en particulier à tous ceux qui exploitent la fonctionnalité de Context API.
L'approche que je vous présente ci-dessous a pour objectif de rendre plus robuste et maintenable l'utilisation d'un Context dans une application à l'échelle. Dans cet article, on suppose que vous n'utilisez qu'un seul Context comme unique source de vérité pour la gestion des données métiers, mais vous pourrez adapter cette astuce à tous vos Contexts.
Sans plus attendre, voici mon modèle, sobrement intitulé "Tuple Context", basé sur une pratique que j'ai pu observer en travaillant avec le framework Solid.
How To Context...
Allons-y étape par étape, en rappelant comment créer un Context avec la librairie React ⚛
import { createContext, useContext, useState } from 'react';
const AppContext = createContext(null);
const initialUser = {
firstName: '',
lastName: '',
email: '',
roles: []
};
export default function AppProvider({ children }) {
const [counter, setCounter] = useState(0);
const incrementCounter = () => setCounter(value => value + 1);
const decrementCounter = () => setCounter(value => value - 1);
const resetCounter = () => setCounter(0);
const [user, setUser] = useState(initialUser);
const fullName = `${user.firstName} ${user.lastName}`;
const setFirstName = firstName => setUser(u => ({ ...u, firstName }));
const setLastName = lastName => setUser(u => ({ ...u, lastName }));
const setEmail = email => setUser(u => ({ ...u, email }));
const addRole = role => setUser(u => ({ ...u, roles: [...u.roles, role] }));
const delRole = role => setUser(u => ({ ...u, roles: u.roles.filter(r => r !== role) }));
const resetUser = () => setUser(initialUser);
const ctxValues = {
counter,
setCounter,
incrementCounter,
decrementCounter,
resetCounter,
user,
fullName,
setUser,
setFirstName,
setLastName,
setEmail,
addRole,
delRole,
resetUser
};
return <AppContext.Provider value={ctxValues}>{children}</AppContext.Provider>;
}
export const useAppContext = () => useContext(AppContext);
Ci-dessus, on déclare un nouveau Context (AppContext
) qui expose deux variables réactives (counter
et user
), une variable réactive dérivée (fullName
), ainsi que des fonctions permettant de faire muter l'état du Context (telles que setCounter
ou setUser
).
On suppose également qu'on utilise correctement le composant déclaré <AppProvider />
en encapsulant l'application, ou a minima les composants <Counter />
et <UserForm />
(d'après l'exemple).
import { useAppContext } from './AppContext';
export default function Counter() {
const { counter, incrementCounter, decrementCounter } = useAppContext();
return (
<div className="my-auto flex flex-col gap-y-2">
<span>Counter Value : {counter}</span>
<div className="mx-auto flex flex-row gap-x-4">
<button
type="button"
onClick={incrementCounter}>
+1
</button>
<button
type="button"
onClick={decrementCounter}>
-1
</button>
</div>
</div>
);
}
Ensuite, en pratique, depuis le composant <Counter />
, on récupère les valeurs relatives à la donnée counter
(qu'il s'agisse d'une variable réactive, ou d'une fonction de modification de l'état), afin d'alimenter la vue de rendu, notamment les événements des boutons "+1/-1".
NB: Ici,
useAppContext
agit comme un raccourci pour injecter les valeurs du Context depuis le composant, évitant ainsi les imports explicites deAppContext
etuseContext
depuis ce dernier.
import { useAppContext } from './AppContext';
function UserForm({ handleSubmit }) {
const { fullName, setFirstName, setLastName, resetUser } = useAppContext();
return (
<form
className="my-auto flex flex-col gap-y-2"
onSubmit={handleSubmit}>
<span>FullName : {fullName}</span>
<div className="flex flex-col gap-y-2">
<label htmlFor="firstName">FirstName</label>
<input
id="firstName"
type="text"
onChange={e => setFirstName(e.target.value)}
/>
</div>
<div className="flex flex-col gap-y-2">
<label htmlFor="lastName">LastName</label>
<input
id="lastName"
type="text"
onChange={e => setLastName(e.target.value)}
/>
</div>
<div className="mx-auto flex flex-row gap-x-4">
<button
type="button"
onClick={resetUser}>
Cancel
</button>
<button type="submit">Submit</button>
</div>
</form>
);
}
De la même manière que pour le composant précédent (à partir de <UserForm />
cependant), on récupère les valeurs spécifiques à la donnée user
sans se soucier de l'organisation du Context.
En effet, puisqu'il s'agit d'un simple objet JSON, on peut donc récupérer les variables user
, fullName
, ainsi que les fonctions setFirstName
, setLastName
, setEmail
, addRole
, delRole
ou encore resetUser
sans difficulté apparente.
Introducing: The Tuple Context Pattern
L'inconvénient de cette conception réside dans la valeur fournie au Context.Provider
, ou plutôt à sa structure... "Il s'agit d'un simple objet JSON"* ! En conséquence, au fur et à mesure que votre application évolue, elle risque d'accueillir un nombre croissant de valeurs (dont 5 variables réactives principales, 10 variables dérivées et 2 dizaines de fonctions de mutation), toutes placées au même niveau.
C'est là qu'intervient le modèle "Tuple Context" !
export default function AppProvider({ children }) {
const [counter, setCounter] = useState(0);
const incrementCounter = () => setCounter(value => value + 1);
const decrementCounter = () => setCounter(value => value - 1);
const resetCounter = () => setCounter(0);
const [user, setUser] = useState(initialUser);
const fullName = useMemo(() => `${user.firstName} ${user.lastName}`, [user]);
const setFirstName = firstName => setUser(u => ({ ...u, firstName }));
const setLastName = lastName => setUser(u => ({ ...u, lastName }));
const setEmail = email => setUser(u => ({ ...u, email }));
const addRole = role => setUser(u => ({ ...u, roles: [...u.roles, role] }));
const delRole = role => setUser(u => ({ ...u, roles: u.roles.filter(r => r !== role) }));
const resetUser = () => setUser(initialUser);
const ctxValues = {
counter,
user,
fullName
};
const ctxDispatchers = {
setCounter,
incrementCounter,
decrementCounter,
resetCounter,
setUser,
setFirstName,
setLastName,
setEmail,
addRole,
delRole,
resetUser
};
return <AppContext.Provider value={[ctxValues, ctxDispatchers]}>{children}</AppContext.Provider>;
}
Comme son nom l'indique, il s'agit de structurer les données exposées par le Context, à la manière d'un tableau de taille fixe (à l'image de useState
).
Ainsi, le tableau est structuré en deux éléments :
- Le 1er paramètre expose les constantes/les variables réactives ;
- Le 2ème paramètre expose les fonctions permettant de faire muter l'état du Context ;
export default function Counter() {
- const { counter, incrementCounter, decrementCounter } = useAppContext();
+ const [{ counter }, { incrementCounter, decrementCounter }] = useAppContext();
return {/* ... */}
}
L'idée est plutôt simple, non !? Rien de révolutionnaire, il suffisait d'y penser 😉
À l'usage, les paramètres du Tuple peuvent s'utiliser indépendamment :
- Soit on extrait exclusivement l'opérande de gauche, dans le cas où l'on ne souhaite manipuler que les valeurs du Context ;
- Soit on extrait l'opérande de droite (en omettant celle de gauche), lorsqu'il s'agit de mettre à jour ces valeurs ;
NB : Dans ce deuxième cas, je recommande d'utiliser la convention "underscore" pour préciser qu'il s'agit d'un paramètre non utilisé, et bénéficier d'une meilleure lisibilité :
const [_, { setCounter, setUser }] = useAppContext()
export default function UserForm() {
- const { fullName, setFirstName, setLastName, resetUser } = useAppContext();
+ const [{ fullName }, { setFirstName, setLastName, resetUser }] = useAppContext();
return {/* ... */}
}
En JavaScript, cela demande une certaine rigueur... En revanche avec TypeScript, c'est un réel bonheur !
TypeScript Forever 💙
À première vue, il n'est pas forcément aisé de typer ce genre de Context (moins intuitif qu'un simple objet JSON)... Cependant, il est nécessaire de décorer les données, afin de faciliter l'utilisation des valeurs exposées, et rendre plus robuste l'import de variables réactives (ou de fonctions de mutation - ou les deux 🙃) à partir des composants.
import React, { createContext, type ReactNode, useContext, useMemo, useState } from 'react';
interface User {
firstName: string;
lastName: string;
email: string;
roles: [];
}
interface AppContextState {
counter: number;
user: User;
fullName: string;
}
interface AppContextDispatchers {
setCounter: React.Dispatch<React.SetStateAction<number>>;
incrementCounter: () => void;
decrementCounter: () => void;
resetCounter: () => void;
setUser: React.Dispatch<React.SetStateAction<User>>;
setFirstName: (value: string) => void;
setLastName: (value: string) => void;
setEmail: (value: string) => void;
addRole: (value: string) => void;
delRole: (value: string) => void;
resetUser: () => void;
}
const AppContext = createContext<[AppContextState, AppContextDispatchers] | null>(null);
const initialUser = {
firstName: '',
lastName: '',
email: '',
roles: []
};
interface AppProviderProps {
children: ReactNode;
}
export default function AppProvider({ children }: AppProviderProps) {
const [counter, setCounter] = useState(0);
const incrementCounter = () => setCounter(value => value + 1);
const decrementCounter = () => setCounter(value => value - 1);
const resetCounter = () => setCounter(0);
const [user, setUser] = useState(initialUser);
const fullName = useMemo(() => `${user.firstName} ${user.lastName}`, [user]);
const setFirstName = firstName => setUser(u => ({ ...u, firstName }));
const setLastName = lastName => setUser(u => ({ ...u, lastName }));
const setEmail = email => setUser(u => ({ ...u, email }));
const addRole = role => setUser(u => ({ ...u, roles: [...u.roles, role] }));
const delRole = role => setUser(u => ({ ...u, roles: u.roles.filter(r => r !== role) }));
const resetUser = () => setUser(initialUser);
const ctxValues = {
counter,
user,
fullName
};
const ctxSetters = {
setCounter,
incrementCounter,
decrementCounter,
resetCounter,
setUser,
setFirstName,
setLastName,
setEmail,
addRole,
delRole,
resetUser
};
return <AppContext.Provider value={[ctxValues, ctxSetters]}>{children}</AppContext.Provider>;
}
export const useAppContext = () => {
const ctx = useContext(AppContext);
if (!ctx) {
throw new Error('useAppContext must be used within an AppProvider');
}
return ctx;
};
That's all folks!
N'hésitez pas à me faire vos retours si l'idée vous plaît, ou si vous utilisez cette conception dans vos applications. Pour la suite, à vous de jouer 😎👌