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 /
écritureet 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 :