🌐EN

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 contexts0 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.