Publié le

Écrire une libraire de gestion de l'état en 50 lignes avec JavaScript

JavaScript

La gestion de l'état est une des parties les plus importantes du développement d'une application web. De l'utilisation de variables globales aux hooks de React en passant par l'utilisation de librairies tierces comme MobX, Redux ou XState pour ne citer que ces 3 là, c'est un des sujets qui alimente le plus les discussions front tant il est important de le maîtriser pour concevoir une application fiable et performante.

Aujourd'hui, je vous propose de construire une mini-librairie de gestion de l'état en moins de 50 lignes de JavaScript basée sur le concept des observables. Celle-ci peut certainement être utilisée telle-quelle pour de petits projets, mais au delà de cet exercice pédagogique je vous recommande tout de même de vous tourner vers des solutions plus standardisées pour vos projets réels.

Définition de l'API

Lorsque l'on démarre un projet de librairie, il est important de définir ce à quoi pourrait ressembler son API dès le début afin de figer son concept et de guider son développement avant même de penser à des détails techniques d'implémentation. Pour un projet réel, il est même possible d'écrire une batterie de tests à ce moment pour valider l'implémentation de la librairie au fur et à mesure de son écriture selon une approche TDD.

Ici, nous souhaitons exporter une seule classe que nous appellerons State qui sera instanciée avec un objet contenant l'état initial et une seule méthode observe qui nous permette de souscrire aux changements d'état avec des observers. Ces observers ne doivent être exécutés que si une de leurs dépendances a été modifiée.

Pour changer l'état, nous souhaitons utiliser les propriétés de classe directement plutôt que de passer par une méthode du type setState.

Parce qu'un extrait de code vaut mille mots, voici à quoi notre implémentation finale pourrait ressembler à l'utilisation :

const state = new State({
  count: 0,
  text: '',
});

state.observe(({ count }) => {
  console.log('Count changed', count);
});

state.observe(({ text }) => {
  console.log('Text changed', text);
});

state.count += 1;
state.text = 'Hello, world!';
state.count += 1;

// Output:
// Count changed 1
// Text changed Hello, world!
// Count changed 2

Implémenter la classe State

Commençons par créer une classe State qui accepte un état initial dans son constructeur et qui expose une méthode observe que nous implémenterons plus tard.

class State {
  constructor(initialState = {}) {
    this.state = initialState;
    this.observers = [];
  }

  observe(observer) {
    this.observers.push(observer);
  }
}

Ici, nous faisons le choix d'utiliser un objet interne state intermédiaire qui nous permettra de conserver les valeurs d'état. Nous stockons également les observers dans un tableau interne observers qui nous servira lorsque nous compléterons cette implémentation.

Comme ces 2 propriétés seront seulement utilisées à l'intérieur de cette classe, nous pourrions les déclarer comme privées avec un petit peu de sucre syntaxique en les préfixant d'un # et en leur ajoutant une déclaration initiale sur la classe :

class State {
  #state = {};
  #observers = [];

  constructor(initialState = {}) {
    this.#state = initialState;
    this.#observers = [];
  }

  observe(observer) {
    this.#observers.push(observer);
  }
}

En principe ce serait une bonne pratique, mais nous allons utiliser les Proxy dans la prochaine étape et ceux-ci ne sont pas compatibles avec les propriétés privées. Sans rentrer dans le détail et pour faciliter cette implémentation, nous utiliserons donc des propriétés publiques pour le moment.

Lire les données de l'objet state avec un Proxy

Lorsque nous avons défini les spécifications de ce projet, nous avons souhaité accéder aux valeurs d'état directement sur l'instance de la classe et non comme une entrée de son objet interne state.

Pour cela, nous allons utiliser un objet proxy qui sera retourné à l'initialisation de la classe.

Comme son nom l'indique, un Proxy permet de créer un intermédiaire pour un objet permettant d'intercepter certaines opérations et notamment ses getters et setters. Dans notre cas, nous créons un Proxy exposant un premier getter qui nous permet d'exposer les entrées de l'objet state comme s'ils appartenaient directement à l'instance de State.

class State {
  constructor(initialState = {}) {
    this.state = initialState;
    this.observers = [];

    return new Proxy(this, {
      get: (target, prop) => {
        if (prop in target.state) {
          return target.state[prop];
        }

        return target[prop];
      },
    });
  }

  observe(observer) {
    this.observers.push(observer);
  }
}

const state = new State({
  count: 0,
  text: '',
});

console.log(state.count); // 0

Dorénavant, nous pouvons définir un objet d'état initial lors de l'instanciation de State puis récupérer ses valeurs directement sur cette instance. Voyons maintenant comment faire pour manipuler ses données.

Ajouter un setter pour modifier les données

Nous avons ajouté un getter, la suite logique est donc d'ajouter un setter nous permettant de manipuler l'objet state.

Nous vérifions d'abord que la clef appartienne bien à cet objet, vérifions ensuite que la valeur a bel et bien changé pour empêcher les mises à jour inutiles puis mettons enfin à jour l'objet avec la nouvelle valeur.

class State {
  constructor(initialState = {}) {
    this.state = initialState;
    this.observers = [];

    return new Proxy(this, {
      get: (target, prop) => {
        if (prop in target.state) {
          return target.state[prop];
        }

        return target[prop];
      },
      set: (target, prop, value) => {
        if (prop in target.state) {
          if (target.state[prop] !== value) {
            target.state[prop] = value;
          }
        } else {
          target[prop] = value;
        }
      },
    });
  }

  observe(observer) {
    this.observers.push(observer);
  }
}

const state = new State({
  count: 0,
  text: '',
});

console.log(state.count); // 0
state.count += 1;
console.log(state.count); // 1

Nous avons maintenant terminé la partie lecture et écriture de données. Nous pouvons changer la valeur de l'état puis récupérer ce changement. Jusque-là notre implémentation n'est pas très utile, implémentons donc maintenant les observers.

Implémentation des observers

Nous avons déjà un tableau contenant les fonctions observers déclarées sur notre instance. Il ne nous reste donc plus qu'à les appeler un par un dès qu'une valeur a été modifiée.

class State {
  constructor(initialState = {}) {
    this.state = initialState;
    this.observers = [];

    return new Proxy(this, {
      get: (target, prop) => {
        if (prop in target.state) {
          return target.state[prop];
        }

        return target[prop];
      },
      set: (target, prop, value) => {
        if (prop in target.state) {
          if (target.state[prop] !== value) {
            target.state[prop] = value;

            this.observers.forEach((observer) => {
              observer(this.state);
            });
          }
        } else {
          target[prop] = value;
        }
      },
    });
  }

  observe(observer) {
    this.observers.push(observer);
  }
}

const state = new State({
  count: 0,
  text: '',
});

state.observe(({ count }) => {
  console.log('Count changed', count);
});

state.observe(({ text }) => {
  console.log('Text changed', text);
});

state.count += 1;
state.text = 'Hello, world!';

// Output:
// Count changed 1
// Text changed 
// Count changed 1
// Text changed Hello, world!

Super, nous réagissons maintenant aux changement de données !

Petit problème cependant. Si vous êtes restés attentifs jusque là, nous voulions initialement exécuter les observers que si lorsqu'une de leurs dépendances a changé. Or, si nous exécutons ce code nous voyons que chaque observer s'exécute à chaque fois qu'une partie de l'état est modifié.

Mais alors comment identifier les dépendances de ces fonctions ?

Identifier les dépendances d'une fonction avec les Proxy

Encore une fois, les Proxy viennent à notre rescousse. Pour identifier les dépendances de nos fonctions observers, nous pouvons créer un proxy de notre objet d'état state, les exécuter avec celui-ci comme argument et noter au passage à quelles propriétés elles ont accédé.

Simple, mais diablement efficace.

Lors de l'appel des observers, il ne nous reste donc plus qu'à vérifier si ceux-ci ont une dépendance vers la propriété mise à jour et de les déclencher uniquement le cas échéant.

Voici l'implémentation finale de notre mini-librairie avec cette dernière partie ajoutée. Vous noterez que le tableau observers contient désormais des objets permettant ainsi de conserver les dépendances de chaque observer.

class State {
  constructor(initialState = {}) {
    this.state = initialState;
    this.observers = [];

    return new Proxy(this, {
      get: (target, prop) => {
        if (prop in target.state) {
          return target.state[prop];
        }

        return target[prop];
      },
      set: (target, prop, value) => {
        if (prop in target.state) {
          if (target.state[prop] !== value) {
            target.state[prop] = value;

            this.observers.forEach(({ observer, dependencies }) => {
              if (dependencies.has(prop)) {
                observer(this.state);
              }
            });
          }
        } else {
          target[prop] = value;
        }
      },
    });
  }

  observe(observer) {
    const dependencies = new Set();

    const proxy = new Proxy(this.state, {
      get: (target, prop) => {
        dependencies.add(prop);
        return target[prop];
      },
    });

    observer(proxy);
    this.observers.push({ observer, dependencies });
  }
}

const state = new State({
  count: 0,
  text: '',
});

state.observe(({ count }) => {
  console.log('Count changed', count);
});

state.observe(({ text }) => {
  console.log('Text changed', text);
});

state.observe((state) => {
  console.log('Count or text changed', state.count, state.text);
});

state.count += 1;
state.text = 'Hello, world!';
state.count += 1;

// Output:
// Count changed 0
// Text changed 
// Count or text changed 0 
// Count changed 1
// Count or text changed 1 
// Text changed Hello, world!
// Count or text changed 1 Hello, world!
// Count changed 2
// Count or text changed 2 Hello, world!

Et voilà, en 45 lignes de code nous avons implémenté une mini librairie de gestion d'état en JavaScript.

Aller plus loin

Si nous voulions aller plus loin, nous pourrions ajouter des suggestions de type avec JSDoc ou réécrire celle-ci en TypeScript pour obtenir des suggestions sur les propriétés du type state.count.

Nous pourrions également ajouter une méthode unobserve qui serait exposée sur un objet retourné par State.observe.

Il pourrait être également utile d'abstraire le fonctionnement du setter dans une méthode setState qui nous permette de modifier plusieurs propriétés en même temps. Actuellement, nous devons modifier chaque propriété de notre état une par une ce qui risque de déclencher nos observers plusieurs fois si certains d'entre eux partagent des dépendances.

En tout cas, j'espère que ce petit exercice vous a plu autant qu'à moi et qu'il vous a permis d'approfondir un petit peu plus la notion de Proxy en JavaScript.