[DIY] State Management - React Vs. Vue ūüĆÄ

Cover Image

Publié sur dev.to et medium.com

Les années passent, les technologies évoluent, et pourtant certains patterns de développement restent intacts, voire se bonifient avec le temps... C'est le cas du State Management !

Introduit avec Flux, une architecture pensée par Facebook pour Facebook, le State Management est aujourd'hui un incontournable du développement Web. Ce paradigme de développement se caractérise principalement par son flux de données unidirectionnel ; en opposition à la liaison bidirectionnelle de données utilisée par les frameworks MVW tel qu'AngularJS, ou plus récemment Vue. Flux est créé pour résoudre certains défauts du pattern MVVM (Model-View / View-Model), notamment lors de la montée à l'échelle d'applications Web. Avec la création du concept de "store", il n'y a plus (ou en tout cas moins) de problème lié aux différentes sources de données. Tout est alors centralisé au même endroit. On dit que le store est unique source de vérité !

Tout comme les fonctions callbacks, les promesses ou encore les streams, le State Management est un paradigme de programmation "réactive". L'idée d'un tel développement est que les composants utilisant les données du store, réagissent lorsque ces mêmes données sont mises à jour. Pour cela, le State Management suit une certaine logique :

  • Le store est en lecture seule
  • Les donn√©es sont mises √† jour par un "dispatcher"
  • Le dispatcher est sollicit√© par des actions
  • L'interface utilisateur d√©clenche les actions

Selon les implémentations de Flux, la notion de "dispatcher" est plus ou moins explicite, cependant le flux de données reste le même : la vue dispatche les actions qui mettent à jour les données du store et implicitement mettent à jour les vues associées à ces données. Dans le monde du développement Web, il existe beaucoup d'implémentations différentes de Flux : Fluxxor, MobX, Overmind, etc... Redux et Vuex sont respectivement les plus connues pour les écosystèmes React et Vue.

Bien que ces dépendances soient extrêmement pratiques et facilite grandement le travail du développeur, il est possible de construire soit même une architecture de State Management. C'est ce qui nous amène à cet article !

Ci-dessous, nous allons voir comment coder son propre Redux / Vuex, étapes par étapes, en utilisant les dernières versions des outils React et Vue actuellement disponibles (version 17 pour la librairie de Facebook, et version 3 pour le framework communautaire).

NB : React utilise les hooks, et Vue utilise l'API Composition. Ces dernières fonctionnalités étant très similaires, il va être intéressant de voir comment celles-ci s'interfacent dans ce genre de développement.

La mise en oeuvre d'un State Management (que ce soit en React ou en Vue) se divise en deux parties :

  • Le Provider qui initialise la m√©canique du store
  • Les Consumers qui interagissent avec le store, en lecture / √©criture et en "dispatchant" les actions

Part 1 - Le Provider

La cr√©ation d'un store avec la librairie de Facebook s'effectue gr√Ęce √† la combinaison astucieuse des hooks et de l'API Context. La cr√©ation d'un contexte nous permet d'avoir acc√®s au composant <Provider /> qui se chargera d'int√©grer les donn√©es du store fournies par le hook useReducer(). En effet, le pattern de d√©veloppement "State - Reducer" occupe une place importante dans la gestion d'un √©tat complexe d'un composant.

import { createContext, useReducer } from 'react';

const initialState = {};

export const store = createContext(initialState);

const { Provider } = store;

const rootReducer = (state, { type, payload }) => {
  switch (type) {
    case 'SET_FIELD':
      return {
        ...state,
        [payload.key]: payload.value
      };

    case 'RESET_FIELD':
      return {
        ...state,
        [payload]: undefined
      };

    default:
      throw new Error();
  }
};

function StateProvider({ children }) {
  const [state, dispatch] = useReducer(rootReducer, initialState);

  return <Provider value={{ state, dispatch }}>{children}</Provider>;
}

export default StateProvider;

StateProvider.jsx

Ces quelques lignes suffisent à instaurer une mécanique de store. Cependant, afin de répandre les données (issues d'un contexte) aux composants enfants, il faut encore encapsuler ces mêmes composants par le composant parent (<StateProvider />), de préférence au plus haut niveau de l'application.

import StateProvider from './StateProvider';
import StateConsumer from './StateConsumer';

function App() {
  return (
    <StateProvider>
      <StateConsumer />
    </StateProvider>
  );
}

export default App;

App.jsx


Pour ce qui est du framework communautaire, gr√Ęce √† la version 3 de Vue, l'initialisation du store repose majoritairement sur l'API Composition, ainsi que sur le pattern de d√©veloppement "Provide / Inject". Cette derni√®re fonctionnalit√© (d√©j√† pr√©sente dans Vue 2) est tr√®s similaire √† l'API Context de React, et permet d'√©tendre des donn√©es globales √† toute une partie de l'application.

<template>
  <slot />
</template>

<script>
import { reactive, provide, readonly } from 'vue';

export default {
  name: 'StateProvider',
  setup() {
    const state = reactive({});

    provide('STATE', readonly(state));

    const setField = (key, value) => {
      state[key] = value;
    };

    const resetField = key => {
      state[key] = undefined;
    };

    provide('SET_FIELD', setField);
    provide('RESET_FIELD', resetField);
  }
};
</script>

StateProvider.vue

Ci-dessus (les fonctions étant relativement explicites) on s'aperçoit rapidement que l'on déclare une variable réactive (l'état global de l'application), puis on la rend disponible, ainsi que les fonctions permettant de la faire muter. Ensuite (et tout comme React), il ne suffit pas d'injecter les données du store dans les composants enfants pour interagir avec ce dernier, il faut également "wrapper" ces mêmes composants par le composant parent (<StateProvider /> à nouveau), responsable du store.

<template>
  <StateProvider>
    <StateConsumer />
  </StateProvider>
</template>

<script>
import StateProvider from './StateProvider';
import StateConsumer from './StateConsumer';

export default {
  name: 'App',
  components: {
    StateProvider,
    StateConsumer
  }
};
</script>

App.vue

Part 2 - Le Consumer

NB : Dans la suite de cet article, les classes CSS que vous retrouverez dans le rendu des composants <StateConsumer /> sont issues d'un framework UI : Bulma !

Une fois le composant enfant encapsul√© par le composant garant du store, on r√©cup√®re ses donn√©es gr√Ęce √† la fonction inject() avec le framework Vue. Le param√®tre de cette fonction n'est autre qu'un identifiant unique, qui r√©f√®re √† la variable / la fonction fourni(e) pr√©alablement par le composant parent.

<template>
  <div class="columns">
    <div class="column">
      <div class="field">
        <div class="label">FullName *</div>
        <div class="control">
          <input
            class="input"
            :value="state['fullName'] || ''"
            @input="$event => setField('fullName', $event.target.value)"
          />
        </div>
      </div>
    </div>
    <div class="column">
      <button class="button" @click="() => resetField('fullName')">
        Reset
      </button>
    </div>
  </div>
</template>

<script>
import { inject } from 'vue';

export default {
  name: 'StateConsumer',
  setup() {
    const state = inject('STATE');
    const setField = inject('SET_FIELD');
    const resetField = inject('RESET_FIELD');

    return {
      state,
      setField,
      resetField
    };
  }
};
</script>

StateConsumer.vue

L'option setup() va donc transmettre l'état du store, ainsi que les fonctions permettant de le mettre à jour, au composant fils <StateConsumer /> avant de monter ce dernier. Dans le template ci-dessus, on utilise directement la valeur state.fullName du store, et on la met à jour soit lorsque l'événement onchange est déclenché par l'<input>, soit lorsque l'événement onclick est joué par le <button>.


Du c√īt√© de la libraire React, la r√©cup√©ration des valeurs du store (√† savoir son √©tat, ainsi que la fonction dispatch()) s'effectue au travers d'un autre hook : useContext(). En important le contexte du store, et en le passant en param√®tre de cette fonction, un composant "stateless" se "connect" (r√©f√©rence √† Redux) au store de l'application.

import { useContext } from 'react';
import { store } from './StateProvider';

function StateConsumer() {
  const { state, dispatch } = useContext(store);

  const setField = (key, value) => dispatch({ type: 'SET_FIELD', payload: { key, value } });
  const resetField = key => dispatch({ type: 'RESET_FIELD', payload: key });

  return (
    <div className="columns">
      <div className="column">
        <div className="field">
          <div className="label">FullName *</div>
          <div className="control">
            <input
              className="input"
              defaultValue={state['fullName'] || ''}
              onChange={e => setField('fullName', e.target.value)}
            />
          </div>
        </div>
      </div>
      <div className="column">
        <button className="button" onClick={() => resetField('fullName')}>
          Reset
        </button>
      </div>
    </div>
  );
}

StateConsumer.jsx

Reste encore à mettre à jour le store… Pour cela, il suffit de dispatcher une action. Par convention, une action est un objet disposant de deux propriétés :

  • Le "type" servant de r√©f√©rence au dispatcher
  • Le "payload" sur lequel le store s'appuie pour mettre √† jour son √©tat

Hooks Vs. API Composition

L'introduction des hooks avec React 16.8 et l'apparition de l'API Composition de Vue 3, modifient notre façon d'utiliser le store. Déjà présente depuis la version 7.1.0 de la dépendance "React-Redux", les hooks (useSelector() / useDispatch()) facilitent grandement la "connexion" avec le store, et évitent une démarche de HOC (High Order Component), pour passer certaines données d'un composant parent, vers les propriétés d'un composant enfant. L'API Composition de Vue, peut-être utilisé de manière très similaire aux hooks de React.

import { useContext } from 'react';
import { store } from './StateProvider';

export default function useField(key) {
  const { state, dispatch } = useContext(store);

  const setField = key => value => dispatch({ type: 'SET_FIELD', payload: { key, value } });
  const resetField = key => () => dispatch({ type: 'SET_FIELD', payload: key });

  return [state[key] || '', setField(key), resetField(key)];
}

hooks/useField.jsx

import { inject, computed } from 'vue';

export default function useField(key) {
  const state = inject('STATE');
  const setField = inject('SET_FIELD');
  const resetField = inject('RESET_FIELD');

  const setFieldByKey = key => value => setField(key, value);
  const resetFieldByKey = key => () => setField(key);

  return [computed(() => state[key] || ''), setFieldByKey(key), resetFieldByKey(key)];
}

composition/useField.jsx

Cette manière de faire est de plus en plus répandue dans les développements Web, et répond au principe suivant : découper pour mieux régner ; Parfait pour les applications comptant plus de 100 composants...

NB : La convention veut que le nom de ce genre de fonction débute par "use" pour préciser qu'il s'agit d'une fonction de composition / d'un hook personnalisé.

Ce concept est plut√īt intelligent, et permet de penser nos applications plus finement, brique par brique. Cela favorise ainsi la r√©usabilit√© du code pour les composants ayant la m√™me logique : la m√™me fa√ßon de lire le store et / ou de mettre √† jour tout ou partie du store.

- import { useContext } from 'react';
- import { store } from './StateProvider';
+ import useField from './hooks/useField';

function StateConsumer() {
-   const { state, dispatch } = useContext(store);
+   const [fullName, setFullName, resetFullName] = useField('fullName');

-   const setField = (key, value) => dispatch({ type: 'SET_FIELD', payload: { key, value } });
-   const resetField = key => dispatch({ type: 'RESET_FIELD', payload: key });

  return (
    <div className="columns">
      <div className="column">
        <div className="field">
          <div className="label">FullName *</div>
          <div className="control">
            <input
              className="input"
-               defaultValue={state['fullName'] || ''}
+               defaultValue={fullName}
-               onChange={e => setField('fullName', e.target.value)}
+               onChange={e => setFullName(e.target.value)}
            />
          </div>
        </div>
      </div>
      <div className="column">
-         <button className="button" onClick={() => resetField('fullName')}>
+         <button className="button" onClick={resetFullName}>
          Reset
        </button>
      </div>
    </div>
  );
}

StateConsumer.jsx

<template>
  <div class="columns">
    <div class="column">
      <div class="field">
        <div class="label">FullName *</div>
        <div class="control">
          <input
            class="input"
-             :value="state['fullName'] || ''"
+             :value="fullName"
-             @input="$event => setField('fullName', $event.target.value)"
+             @input="$event => setFullName($event.target.value)"
          />
        </div>
      </div>
    </div>
    <div class="column">
-       <button class="button" @click="() => resetField('fullName')">
+       <button class="button" @click="resetFullName">
        Reset
      </button>
    </div>
  </div>
</template>

<script>
- import { inject } from 'vue';
+ import useField from './composition/useField';

export default {
  name: 'StateConsumer',
  setup() {
-     const state = inject('STATE');
-     const setField = inject('SET_FIELD');
-     const resetField = inject('RESET_FIELD');
+     const [fullName, setFullName, resetFullName] = useField('fullName');

    return {
-       state,
-       setField,
-       resetField
+       fullName,
+       setFullName,
+       resetFullName
    };
  }
};
</script>

StateConsumer.vue

NB : L'avantage de la fonction ci-dessus, est qu'elle g√®re directement la valeur par d√©faut du "field" si la valeur de ce dernier n'est pas (encore) pr√©sente dans le store ; plut√īt qu'une gestion dans le template du composant.

Conclusion

J'utilise le State Management depuis plusieurs ann√©es maintenant, souvent avec Redux (en parall√®le avec Vuex), j'ai appris √† conna√ģtre son fonctionnement et ses nombreux avantages.

Bien qu'extrêmement pratique, le State Management prend tout son sens dans une application Web à l'échelle, avec une multitude de composants, ainsi que de multiples fonctionnalités. La centralisation des données, leur lecture et leurs mises à jour deviennent alors plus aisées.

Les dernières versions des frameworks / librairies JavaScript nous amène à décomposer plus finement nos applications. L'usage des hooks / de l'API Composition rend le State Management plus accessible et clair (plus besoin de dépendances supplémentaires mystifiant une partie du code). Il m'arrive donc aujourd'hui d'utiliser cette logique de développement à plus petite échelle (pour construire des Single Page Apps avec moins de 50 composants par exemple).

Vous l'aurez compris, je suis assez fan de la programmation r√©active par l'usage d'un store. Si vous d√©veloppez r√©guli√®rement avec Redux (ou Vuex), ou m√™me d'autres librairies (RxJS), je vous invite √† faire cet exercice de cr√©ation d'un State Management from scratch (pour le plaisir du code ūüėé).

Finalement, cette comparaison entre React et Vue, permet de se rendre compte que ces deux frameworks gravitant autour d'un DOM Virtuel ne sont pas aussi éloignés l'un de l'autre, malgré des concepts qui leurs sont propres (tel que la paire "Provide / Inject" de Vue). À la suite de cet article, je pense réitéré ce travail sur d'autres outils, surement Svelte dont certaines notions sont assez proches de React et Vue. Cela donnera probablement suite à un article dérivé...

Sources

Dans le cadre de cet article, j'ai réalisé deux projets de démonstration (visuellement identiques) en appliquant l'architecture explicité précédemment. Voici les liens :