Peut-être un air de déjà-vu ? En fait non ! La semaine dernière je publiais un article très générique sur comment rendre plus réactive vos applications web à l'environnement -- article que vous pouvez retrouver ici -- cette semaine je vous montre du code pour faire exactement ça !

À noter que je vais me baser sur le code d'un projet que j'ai réalisé pour un talk sur le sujet : https://github.com/kuroidoruido/talks/tree/master/2024/02-22_react-nantes_env-reactive-web-app/demo

Récupération de la configuration initiale

Pour commencer je vous propose qu'on récupère la configuration initiale, histoire de pouvoir démarrer notre application. De préférence on va le faire avant / en parallèle de React pour éviter de perdre du temps et pour avoir une mécanique qui ne dépend que très peu de React !

// src/env.ts

export interface Env {
  config: {
    apiUrl: string;
  };
}

const DEFAULT_ENV_STATE: Env = {
  config: {
    apiUrl: "",
  },
};

class EnvListener {
  private state: Env = DEFAULT_ENV_STATE;

  constructor() {
    this.getConfig();
  }

  public getEnv(): Readonly<Env> {
    return this.state;
  }

  private getConfig() {
    return fetch("/config.json")
      .then((res) => res.json())
      .then((config) => {
        this.state.config = config;
      });
  }
}

export const envListener = new EnvListener();

J'ai fait quelque chose de simple mais efficace : une classe avec un état interne qu'on va initialiser avec des valeurs par défaut pour ne pas casser notre application si on met du temps ou qu'on arrive pas à charger la config, config qu'on va aller chercher dès le démarrage avec un simple fetch. Pour récupérer cet état on peut appeler la méthode envListener.getEnv().

Je vous le dis franchement : en l'état, ça ne fonctionnera pas bien. Déjà on a aucune intégration React, notre état est muté en interne ce qui n'est pas très "React-way" et on a aucun moyen de prévenir un éventuel consommateur que l'état a changé.

Rendre écoutable et immutable notre état

Tout est dans le titre de cette partie : on doit rendre écoutable notre état et le rendre immutable pour être certain de facilement pouvoir savoir qu'une mise à jour a eu lieu (React faisant un simple === pour savoir si un état ou une props a changé c'est important). Par facilité je vais utiliser immer (j'aurai aussi pu utiliser mutative, une autre lib ou le faire à la main, j'ai choisi immer par pure habitude).

Pour gérer le côté "écoutable" le plus simple est d'avoir un mécanisme d'event listener. Je vous propose d'utiliser une mécanique 100% native : les Event. Je vais créer un événement spécifique AppNewEnvStateEvent qui va étendre Event, et je vais déclarer cette Event sur Window pour que tout soit correctement typé ! Ensuite je n'ai plus qu'à ajouter une méthode subscribe et unsubscribe qui viennent cacher des appels à window. J'en profite pour ajouter une méthode update qui va masquer une partie de la gestion de la mise à jour, avec entre autres un dispatch d'Event pour me faciliter la vie plus tard. Ensuite notre getConfig utilise maintenant la méthode update.

// src/env.ts

import { produce } from "immer";

// ...

export class EnvListener {
  // ...

  public subscribe(cb: (env: AppNewEnvStateEvent) => void) {
    window.addEventListener("app.new-env-state", cb);
  }

  public unsubscribe(cb: (env: AppNewEnvStateEvent) => void) {
    window.removeEventListener("app.new-env-state", cb);
  }

  private update(editor: (draft: Env) => void) {
    const newState = produce(this.state, editor);
    if (this.state !== newState) {
      const oldEnv = this.state;
      this.state = newState;
      window.dispatchEvent(new AppNewEnvStateEvent(oldEnv, this.state));
    }
  }

  private getConfig() {
    return fetch("/config.json")
      .then((res) => res.json())
      .then((config) =>
        this.update((draft) => {
          draft.config = config;
        })
      );
  }
}

class AppNewEnvStateEvent extends Event {
  constructor(public readonly oldValue: Env, public readonly newValue: Env) {
    super("app.new-env-state", { bubbles: true, composed: true });
  }
}

declare global {
  interface Window {
    addEventListener(
      eventType: "app.new-env-state",
      listener: (event: AppNewEnvStateEvent) => void
    ): void;
    removeEventListener(
      eventType: "app.new-env-state",
      listener: (event: AppNewEnvStateEvent) => void
    ): void;
  }
}

// ...

On the React side!

Et côté React ça donne quoi ? Parce qu'on a toujours pas une intégration pratique là…

Eh bien je vais sortir une carte magique useSyncExternalStore ! C'est une hook que très peu de gens connaissent, et c'est bien normal : il a été prévu pour intégrer des librairies comme Redux qui vont gérer un état de leur côté et permettre synchroniser cet état avec React. Ça ressemble pas mal à ce qu'on veut faire : on a l'état de notre environnement qu'on veut synchroniser avec React.

Je vous le dis : c'est super simple !

// src/hooks/useEnv.ts

import { useSyncExternalStore } from "react";
import { envListener } from "../env";

export function useEnv() {
  return useSyncExternalStore((cb) => {
    envListener.subscribe(cb);
    return () => envListener.unsubscribe(cb);
  }, envListener.getEnv.bind(envListener));
}

Voilà ! Je vous explique quand même !

Le premier paramètre c'est une fonction pour s'abonner a aux changements, cette fonction doit retourner une fonction pour se désabonner (comme on ferait avec un useEffect : on passe ce qu'on veut faire, puis on retourne une fonction de nettoyage). Le second paramètre est une fonction pour récupérer un état à un instant T.

À noter que je suis obligé de bind ma méthode pour qu'elle concerve son contexte, mais j'aurai pu aussi passer () => envListener.getEnv() à la place, faire un bind évite de créer une nouvelle fonction 🤷

Vous avez maintenant un hook que vous pouvez utiliser partout où vous le souhaitez pour récupérer votre état !

Et pour le reste de l'env ?

Si vous avez lu mon précédent article je parle d'un peu plus que de configuration, je parle aussi de toggle statiques, de toggle dynamiques et de healthcheck.

Pour intégrer ça il nous faut qu'on récupère une première fois l'état et qu'on puisse mettre à jour cet état à rythme régulier. On pourrait utiliser du websocket pour faire du push, mais la solution la plus simple à mettre en place c'est de faire du polling : on va appeler à rythme régulier notre état serveur pour le récupérer !

À noter que dans mon exemple je suis agressif sur le temps entre deux appels, je vous laisse évidemment ajuster à votre besoin pour ne pas spammer votre backend et ne pas gaspiller des ressources, l'écologie c'est important !

Déjà notre état va agrandir un peu notre état, ensuite on va ajouter des méthodes pour gérer tout ça :

// src/env.ts

// ...

export interface Env {
  config: {
    apiUrl: string;
  };
  toggles: {
    static: {
      assistant: boolean;
    };
    dynamic: {
      accounts: boolean;
      transactions: boolean;
      cards: boolean;
      assistant: boolean;
    };
  };
  health: {
    bff: HealthStatus;
  };
}

const DEFAULT_ENV_STATE: Env = {
  config: {
    apiUrl: "",
  },
  toggles: {
    static: {
      assistant: false,
    },
    dynamic: {
      accounts: true,
      cards: true,
      transactions: true,
      assistant: true,
    },
  },
  health: {
    bff: "ok",
  },
};

export class EnvListener {
  // ...

  constructor() {
    this.getConfig().then(() => {
      this.watchHealth();
    });
    this.watchToggle();
  }

  private watchToggle() {
    this.getToggle();
    setInterval(() => this.getToggle(), 5_000);
  }
  private getToggle() {
    return fetch("/toggle.json")
      .then((res) => res.json())
      .then((toggles) =>
        this.update((draft) => {
          draft.toggles.static = toggles;
        })
      );
  }

  private watchHealth() {
    this.getHealth();
    setInterval(() => this.getHealth(), 1_000);
  }
  private getHealth() {
    return fetch(this.state.config.apiUrl + "/health")
      .then((res) => res.json())
      .then((healths: Health) => {
        this.update((draft) => {
          draft.health.bff = healths.health;
          draft.toggles.dynamic.accounts =
            healths.dependencies?.find((api) => api.name === "Accounts API")
              ?.health !== "ko";
          draft.toggles.dynamic.cards =
            healths.dependencies?.find((api) => api.name === "Cards API")
              ?.health !== "ko";
          draft.toggles.dynamic.transactions =
            healths.dependencies?.find((api) => api.name === "Transactions API")
              ?.health !== "ko";
          draft.toggles.dynamic.assistant =
            healths.dependencies?.find((api) => api.name === "DumbLLM API")
              ?.health !== "ko";
        });
      })
      .catch(() => {
        this.update((draft) => {
          draft.health.bff = "ko";
          draft.toggles.dynamic.accounts = false;
          draft.toggles.dynamic.cards = false;
          draft.toggles.dynamic.transactions = false;
          draft.toggles.dynamic.assistant = false;
        });
      });
  }
}

type HealthStatus = "ok" | "partial" | "ko";
interface HealthDeps {
  name: string;
  url: string;
  health: HealthStatus;
  optional: boolean;
}
interface Health {
  health: HealthStatus;
  dependencies?: HealthDeps[];
}

// ...

Rien de bien compliqué ici : le code parle de lui-même à mon avis.

Conclusion

Ce que je vous propose ici est une gestion de l'environnement très simple mais parfaitement fonctionnel ! C'est le genre de mécanique que j'ai déjà utilisé dans le passé et c'est éprouvé. Je vous conseille vraiment de ne pas chercher à écrire tout ça directement à base de hook pour garder plusieurs avantages :

  • vous pouvez charger tout ça en parallèle de React (voir avant React et faire un bootstrap différent de React au besoin en fonction de la configuration ou d'une toggle)
  • vous n'aurez aucun re-rendu intempestif avec cette structure (quoi qu'on en dise : React gère mal les états)
  • vous pouvez utiliser envListener complètement hors de React
  • vous aurez juste un copier-coller à faire pour utiliser la même chose dans un autre framework

Crédit photo : https://pixabay.com/photos/dominoes-small-stop-corruption-665547/