Comment se passer de store manager
Faire du front revient souvent à utiliser une librairie de state management en plus d'un framework. Mais parfois ce n'est pas la solution la plus optimisé. Voici un exemple de pourquoi et comment j'ai fait autrement.
Un peu de contexte
Chez Nextstore, nous Ă©ditions une interface permettant n'importe qui de fabriquer leur site e-commerce en no-code
.
Les utilisateurs pouvaient drag and drop0 tous les composants de leurs sites, en éditer le contenu et le style, et customiser la maniÚre dont ils accédaient à leurs données sur Shopify ou BigCommerce.
Le code généré par notre builder leur était accessible via un repo git, contenant leur site, mais également nos composants.
Nous fournissions en effet des composants prĂ©-faits, avec une grosse orientation mobile. LâidĂ©e Ă©tait dâoptimiser le trafic sur mobile en amĂ©liorant les performances, mais aussi lâUX grĂące Ă des interactions plus engageantes et typiques du mobile, tel que le swipe ou le snap-scroll0
.
Le plus bel exemple Ă©tait le Drawer
, un conteneur tactile glissant Ă la verticale permettant du progressive-disclosure0
Ce composant Ă©tait gĂ©nĂ©ralement utilisĂ© dans les pages produits de nos clients pour mettre en valeur le carrousel dâimage, mais les utilisateurs pouvaient le mettre nâimporte oĂč ils le jugeaient nĂ©cessaire.
Le Drawer
est un composant simple, mais dont la position dans lâĂ©cran peut impliquer tout un tas de changement pour le reste de lâapp. Le rĂ©trĂ©cissement dâun carrousel dâimage par exemple, la diminution dâune taille de police ailleurs, peu importe.
Dans la plupart des librairies de type React ou Vue, lâĂ©tat â ou state
â responsable de tous ces changements est externalisĂ© dans un store
via une librairie de state management. Comme Redux, VueX ou MobiX.
Par exemple, notre builder qui est une application stable, aux fonctionnalités et nombre de pages défini utilisait extensivement Jotai0.
Dans le cas des sites clients, ce fut différent.
Lâapp infinie
Dâabord, il nâĂ©tait pas possible de prĂ©dire exactement ce Ă quoi chaque app ressemblerait. Fabriquer un builder nâest pas comme coder une fonctionnalitĂ© pour un site en particulier. Lâobjectif est dâoffrir le choix Ă lâutilisateur. Chacun de nos composants offrait la possibilitĂ© dâĂȘtre utilisĂ© nâimporte oĂč, autant de fois que nĂ©cessaire ou pas du tout. Donc exit toutes les solutions du style XState
.
Cela nâempĂȘche pas dâutiliser un store, mais cela rend la tĂąche plus compliquĂ©e. Nous avons dâabord essayĂ© avec Redux, puis Recoil, avant de faire lâerreur classique de vouloir rĂ©inventer la poudre avec des contexts
React.
Mais que ce soit avec les contexts
0 ou une autre librairie, la maintenance du tronc commun de toutes nos app était compliqué.
Parce que les composants pouvaient ne pas ĂȘtre lĂ , nous devions mettre tout un tas de garde-fou, nous perdions souvent les avantages du SSR et les performances nâĂ©taient vraiment pas bonnes malgrĂ© tous nos efforts. Nous avions lâimpression de nous battre contre des fantĂŽmes.
Ensuite, sur une application classique, seuls les devs dâune mĂȘme boite interviennent sur la codebase. Au pire, il y a des affrontements inter-Ă©quipes, mais rarement plus de drama. Nous offrions lâaccĂšs Ă notre codebase Ă nos clients qui pouvaient en faire potentiellement nâimporte quoi. Et nous ne souhaitions pas que ce nâimporte quoi puisse facilement atteindre nos composants.
En ce sens, le principe du store avec lequel nâimporte qui peut interagir nâimporte comment ne nous convenait pas. Nous voulions que nos state
restent le plus proche possible de leur UI. Surtout, nous voulions avoir le contrĂŽle sur la maniĂšre dont les utilisateurs modifieraient nos state
depuis leur code.
Il Ă©tait donc nĂ©cessaire dâavoir une architecture plus lĂ©gĂšre, flexible et dynamique, capable de sâadapter Ă tous les cas de figures.
La question Ă©tait donc : laquelle ?
Le JS natif Ă la rescousse
Jâadore faire de la veille technique. Et un jour, en parcourant MDN pour le plaisir, je suis tombĂ© sur la page des Custom Events
. Et ça m'a donnée une idée.
Si vous avez déjà fait du front, alors vous avez certainement utilisé les events
natifs du DOM, comme "click"
ou encore "scroll"
.
Et bien, vous pouvez fabriquer les vĂŽtres sans mettre en pĂ©ril lâInternet mondial. Voyez plutĂŽt :
La classe CustomEvent
vous permet dâenregistrer un Ă©vĂšnement de votre cru avec un nom de votre invention et de faire passer des informations complĂ©mentaires au moment du dispatch
.
const monEvent = new CustomEvent("mon-event", { detail: { ...myData } });
document.dispatchEvent(monEvent);
// Ailleurs dans mon app
document.addEventListener("mon-event", (event: CustomEvent<TypeDeMaData>) => {
const { ...myData } = event.detail;
/// faire un truc cool avec.
});
Dans notre cas, câest exactement ce dont nous avions besoin. Un moyen de faire circuler lâinformation de nos states internes dans le reste de lâapp avec un systĂšme de souscription.
Le code
Sur chacun de nos composants, nous avons alors dĂ©fini une liste dâĂ©vĂšnements Ă traquer, comme la position de notre fameux
Drawer
.
type Data = {/*...*/}
class MonEvent extends CustomEvent<Data> {
static eventName = (id:string) => `event-du composant-${id}`;
constructor(data: Data, id:string) {
super(MonEvent.eventname(id), {
detail: data,
cancellable:false
});
}
}
function ComposantExterne({id, ...props}) {
useEffect(() => {
document.addEventListner(MonEvent.eventName(id), callback);
return () => document.removeEventListner(MonEvent.eventName(id), callback);
}, [id]);
return (
/// mon JSX
);
}
// Ou encore :
function ButtonExterne({id, ...props}) {
const [state, setState] = useState(false);
return (
<button onClick={() => {
const newState = !state; // juste histoire d'ĂȘtre synchro
setState(newState);
document.dispatchEvent(new MonEvent({state: newState ? "cliked" : "unclicked"}, id));
}}>Faire un truc</button>
);
}
Ben voilĂ . Nickel. Il ne nous reste plus quâĂ âhookifierâ cette solution :
// @/hooks/useCustomEventListener.ts
/*
Cette classe imite les autres class events et permet d'envoyer n'importe laquelle dans le hook plus bas tout en profitant du typage de la data
*/
class GlobalEvent<Data> extends CustomEvent<Data> {
static eventName: (() => string) | ((id: string) => string) = () => "";
constructor(data: Data, id?: string) {
super(GlobalEvent.eventName(id ?? ""), { detail: data, cancelable: false });
}
}
type Args<T> = {
customEvent: typeof GlobalEvent<T>,
id?: string,
callback?:(detail: T) => void
};
export function useCustomEventListener<EventDetail>({
customEvent,
id,
callback
}: Args<EventDetail>): EventDetail | undefined {
const [eventValue, setEventValue] = useState<undefined | EventDetail>(
undefined
);
useEffect(() => {
function handleCustomEvent(event: Event): void {
if (event instanceof customEvent) {
setEventValue({ ...event.detail });
}
}
document.addEventListener(
customEvent.eventName(id ?? ""),
handleCustomEvent
);
return () =>
document.removeEventListener(
customEvent.eventName(id ?? ""),
handleCustomEvent
);
}, [customEvent, id]);
return eventValue;
}
// @/components/SomeComponents.tsx
function SomeComponent() {
const { state } = useCustomEventListener(DrawerSwippingEvent);
// A condition que la class DrawerSwippingEvent spécifie sa version de EventDetail,
// il n'est pas nécessaire de le spécifier ici, d'ou l'interet des class.
return <p>Le drawer est {state.position > 0 ? "ouvert" : "fermé"}</p>;
}
La méthode addEventListener
ne permet pas de rĂ©cupĂ©rer dâĂ©ventuels retours de son callback. On est donc obligĂ©s de rĂ©percuter le contenu de lâ event
dans un deuxiĂšme state.
Mais soyez sans crainte, car React est suffisamment optimisĂ©, particuliĂšrement depuis la version 18, pour âbatcherâ toutes ces updates de state en mĂȘme temps, mĂȘme dans un callback, mĂȘme dans de lâasync.
Vous pouvez vĂ©rifier vous mĂȘme dans cet exemple:
Dans l'autre sens
Jusque là nous avons vu comment envoyer de la données, mais nos composants pouvait également recevoir certaines informations, comme par exemple s'ouvrir ou scroller jusqu'à certaine position.
Mais lĂ le code est encore plus simple, il n'y a mĂȘme pas besoin de hook
react puisqu'il s'agit d'une simple fonction. Ecrivons en un tout de mĂȘme pour soigner notre DX :
type Args<T> = {
customEvent: Global<T>;
id?:string;
}
export function useCustomEventEmitter<EventDetail>({
customEvent,
id,
}: Args<EventDetail>) {
return (data: EventDetail) => document.dispatchEvent(new CustomEvent(data, id));
}
Et voilĂ !
Wow ! Est-ce que lâon vient dâinventer un nouveau store-manager rĂ©volutionnaire ?
Absolument pas đ€Ł.
Mais vous remarquerez que cette solution permet Ă peu de frais de faire transiter de l'information entre n'importe quels composants de la page sans avoir Ă utiliser de Wrapper
au sommet de l'application. C'est à dire sans utiliser de store, tout en conservant la réactivité de notre framework préféré.
Dans notre cas, cette organisation nous a été trÚs utile. Que ce soit pour itérer sur nos composants sans les transformer en code spaghetti, ou pour permettre à nos clients de les utiliser plus facilement.
Conclusion
GrĂące Ă cette mĂ©thode, nous nâavions plus besoin de rĂ©flĂ©chir Ă la hiĂ©rarchie des Ă©lĂ©ments dans leurs interactions avec dâautres Ă©lĂ©ments distants dans un contexte dâincertitude. Nous nâavions plus Ă nous prĂ©occuper de rendre certains Ă©lĂ©ments indispensables ou non. Nos composants et ceux de nos clients pouvaient ĂȘtre dĂ©posĂ©s nâimporte oĂč dans notre AST0 maison et discuter avec nâimporte lequel dans la page.
Et c'Ă©tait quand mĂȘme trĂšs pratique.