Assez souvent, on entend dire que Redux est un outil dépassé ou vieillissant et il arrive que les néophytes soient un peu découragés lors de leurs premiers pas.
En 2018, React Context est annoncé et a pour objectif de faire transiter des données au travers de l’arbre de composants sans avoir à passer par les props à chaque niveau.
Cette annonce a inquiété certains utilisateurs de Redux, ceux-ci croyant que la librairie ne serait plus maintenue après l’apparition de la nouvelle feature de React.
Redux est pourtant, comme l’a écrit Mark Erikson (mainteneur majeur de la librairie) “Not dead yet !”.
Dans cet article, je mets en avant une évolution récente de Redux qui permet de réduire considérablement le “boilerplate code” ainsi que l’aspect verbeux de la librairie : Redux toolkit.
Afin d’illustrer mes propos, un projet contenant une branche redux-vanilla et une autre Redux toolkit est mis à disposition sur GitHub. Je m'appuierai sur ce projet au fil de l’article pour comparer les deux approches et mettre en avant l’utilisation de Redux toolkit.
Afin de ne pas surcharger l’article, nous nous concentrerons uniquement sur le code lié à Redux. Bien entendu, n’hésitez pas à suivre avec le repo git en parallèle pour mieux comprendre l'interaction entre Redux et les composants.
Vue d’ensemble de l’application
Commençons tout d’abord par une vue d’ensemble de l’application sur laquelle nous allons nous baser, cela permettra de mieux comprendre le domaine utilisé, ici la gestion de couleurs, et l’intérêt de Redux.
Dans celle-ci, nous allons simplement pouvoir choisir une couleur (red, green, blue) en cliquant sur des boutons radio ainsi qu’une saturation en cliquant sur des boutons “+” et “-”.
Un message combinant ces deux sélections sera alors affiché sur la même page :
Cette simple page est divisée en plusieurs composants :
- ColorSelector
- ColorSaturation
- ColorDisplay
Dans une architecture sans Redux, ils communiqueraient ensemble en passant des informations par leurs props de cette manière :
Grâce à Redux, on réduit le couplage entre les composants et ceux-ci vont directement interagir avec le store :
L’application développée avec Redux “vanilla” :
Nous allons maintenant voir dans le code les différents morceaux de l’application qui fonctionnent entre eux et qui suivent la logique du diagramme précédent. Pour rappel, le code présenté dans cette partie est disponible sur GitHub sur la branche redux-vanilla.
En utilisant Redux de manière classique, nous allons pouvoir voir différents types de fichiers : constants, actions, et reducers.
Les constants :
Les constants vont servir à nommer les actions que le reducer interceptera :
Lien GitHub : src/store/colors/constants.ts
export const SELECT_COLOR = 'SELECT_COLOR';
export const INCREASE_SATURATION = 'INCREASE_SATURATION';
export const DECREASE_SATURATION = 'DECREASE_SATURATION';
On peut d’ores et déjà voir 3 constants (“SELECT_COLOR
”, “INCREASE_SATURATION
”, et “DECREASE_SATURATION
”) qui nous permettront d’identifier 3 actions différentes.
Les actions :
Les actions seront “dispatched” par les composants, c’est-à-dire envoyées aux reducers afin qu’ils les interprètent et mettent à jour l’état du store.
Lien GitHub : src/store/colors/actions.ts
import {Color} from "../../components/color-selector/ColorSelector";
import {DECREASE_SATURATION, INCREASE_SATURATION, SELECT_COLOR} from "./constants";
export interface SelectColorAction {
type: typeof SELECT_COLOR
payload: Color
}
export interface IncreaseSaturation {
type: typeof INCREASE_SATURATION
}
export interface DecreaseSaturation {
type: typeof DECREASE_SATURATION
}
export type ColorsActionTypes = SelectColorAction | IncreaseSaturation | DecreaseSaturation;
export const selectColor = (color: Color): SelectColorAction => ({
type: SELECT_COLOR,
payload: color
});
export const increaseSaturation = (): IncreaseSaturation => ({
type: INCREASE_SATURATION
});
export const decreaseSaturation = (): DecreaseSaturation => ({
type: DECREASE_SATURATION
});
Les reducers :
Notre unique reducer permet de “capter” les actions envoyées par les composants afin de mettre à jour l’état du store.
Lien GitHub : src/store/colors/reducers.ts
import {Color} from "../../components/color-selector/ColorSelector";
import {DECREASE_SATURATION, INCREASE_SATURATION, SELECT_COLOR} from "./constants";
import {ColorsActionTypes} from "./actions";
export interface ColorsState {
current: Color,
saturation: number
}
const initialState: ColorsState = {current: "Red", saturation: 50};
const MAX_SATURATION = 100;
const MIN_SATURATION = 0;
const reducers = (state: ColorsState = initialState, action: ColorsActionTypes): ColorsState => {
const {saturation} = state;
switch (action.type) {
case SELECT_COLOR:
return {
...state,
current: action.payload
}
case INCREASE_SATURATION:
return {
...state,
saturation: saturation === MAX_SATURATION ? MAX_SATURATION : saturation + 1
}
case DECREASE_SATURATION:
return {
...state,
saturation: saturation === MIN_SATURATION ? MIN_SATURATION : saturation - 1
}
default:
return state
}
};
export default reducers;
On peut voir sans surprise que les reducers sont capables de gérer les 3 actions que nous avons listées plus haut.
Ci-dessous les tests unitaires des reducers :
Lien GitHub : src/store/colors/reducers.spec.ts
import {decreaseSaturation, increaseSaturation, selectColor} from "./actions";
import reducers, {ColorsState} from "./reducers";
describe('colors reducer', () => {
const initialState: ColorsState = {current: "Red", saturation: 50};
it.each`
color
${"Blue"}
${"Green"}
${"Red"}
`('should select the $color color', ({color}) => {
const nextState: ColorsState = reducers(initialState, selectColor(color));
expect(nextState).toStrictEqual({...initialState, current: color});
});
it('should not increase the saturation when it is already 100', () => {
const previousState: ColorsState = {current: "Red", saturation: 100};
const nextState: ColorsState = reducers(previousState, increaseSaturation());
expect(nextState).toStrictEqual(previousState);
});
it('should increase the saturation', () => {
const nextState: ColorsState = reducers(initialState, increaseSaturation());
expect(nextState).toStrictEqual({...initialState, saturation: 51});
});
it('should not decrease the saturation when it is already 0', () => {
const previousState: ColorsState = {current: "Red", saturation: 0};
const nextState: ColorsState = reducers(previousState, decreaseSaturation());
expect(nextState).toStrictEqual(previousState);
});
it('should decrease the saturation', () => {
const nextState: ColorsState = reducers(initialState, decreaseSaturation());
expect(nextState).toStrictEqual({...initialState, saturation: 49});
});
});
L’application développée avec redux toolkit :
Nous avons pu voir dans la section précédente une utilisation relativement classique de Redux. Alors que cette utilisation fait très bien le travail, elle devient très rapidement verbeuse et écrire constamment les constants, actions et reducers correspondants demandera du temps au développeur. Pour rappel, le code présenté dans cette partie est disponible sur GitHub sur la branche redux-toolkit.
C’est justement ce temps de développement de “boilerplate” que Redux toolkit aide à éliminer. Un des outils que la librairie propose et que nous allons voir dans la section suivante est le slice.
Introduction au slice :
Lien GitHub : src/store/colors/slice.ts
import {createSlice, PayloadAction} from "@reduxjs/toolkit";
import {Color} from "../../components/color-selector/ColorSelector";
export interface ColorsState {
current: Color,
saturation: number
}
const initialState: ColorsState = {current: "Red", saturation: 50};
const colorsSlice = createSlice({
name: 'colors',
initialState: initialState,
reducers: {
selectColor(state, action: PayloadAction<Color>) {
state.current = action.payload
},
increaseSaturation(state) {
if (state.saturation < 100 ) state.saturation++
},
decreaseSaturation(state) {
if (state.saturation > 0 ) state.saturation--
}
}
});
export default colorsSlice;
Ce sera à présent le seul et unique fichier à modifier pour faire fonctionner notre logique Redux.
L’objet qui nous intéresse est colorsSlice
, il est créé en faisant appel à la méthode createSlice
qui vient de Redux toolkit.
Dans cette méthode, il faut passer un objet qui contient plusieurs propriétés :
name
: correspond au nom du domaine dans le storeinitialState
: l’état initial du domaine dans le storereducers
: les différentes fonctions pures qui ont pour rôle de mettre à jour l’état du domaine dans le store en fonction d’une action reçue
Lors de sa création, le slice va exposer plusieurs propriétés dont 2 qui nous intéressent particulièrement : actions
et reducer
.
Dans les actions, nous allons retrouver des actions créées automatiquement par Redux toolkit qui correspondent aux noms de nos reducers :
colorsSlice.actions.selectColor
colorsSlice.actions.increaseSaturation
colorsSlice.actions.decreaseSaturation
Ce sont ces actions que les composants pourront utiliser et dispatcher de manière classique.
Quant à reducer
, c’est en fait une fonction pure qui prend en paramètre l’état du domaine ainsi qu’une action et qui renvoie un nouvel état du domaine en fonction de l’action qui lui a été passée :
colorsSlice.reducer(someState, someAction)
colorsSlice.reducer(initialState, colorsSlice.actions.decreaseSaturation)
Avoir accès au reducer nous permettra principalement de tester unitairement son comportement.
Pour résumer, un slice va permettre de créer des actions automatiquement à partir du nom du reducer qui aura comme rôle de gérer cette action. Ainsi, on peut utiliser des actions générées par notre slice comme une action classique simplement en déclarant le reducer.
Cette pratique permet de ne plus avoir à maintenir constants, actions, et reducers séparément car tout est implicitement lié à l’implémentation d’une seule entité : le slice.
L'immutabilité grâce à Immer :
Lors de la mise à jour de l’état du store via un reducer, il est impératif de renvoyer un nouvel objet et de ne pas modifier l’état précédent. On appelle cela l’immutabilité. Vous pouvez trouver des informations sur cette règle dans la documentation officielle de Redux.
Cela peut néanmoins devenir un problème lorsque l’on fait face à de nombreux objets imbriqués ou à des tableaux, et que l’utilisation du “spread operator”, une fonctionnalité ES6, devient complexe.
Vous l’avez peut-être remarqué, mais lors de la déclaration des reducers dans notre slice, nous avons modifié l’état et n’avons donc pas respecté le principe d’immutabilité et le fait qu’un reducer doit être une fonction pure.
En réalité, c’est totalement acceptable, grâce à Immer.
Immer est une librairie plutôt performante incluse par défaut dans Redux toolkit qui permet d’écrire nos reducers comme des fonctions impures (modification de l’état directement) sans briser la règle de l'immutabilité. En effet, Immer met en place tout un mécanisme, afin de maintenir ce principe.
Le principe est que l’on applique des modifications à un état “brouillon”, qui est un clone de l'état actuel. Une fois que toutes les mutations sont terminées, Immer génère l'état suivant basé sur les mutations de l'état de “brouillon”.
Une fois de plus, cela permet au développeur d’écrire du code moins complexe, moins verbeux, plus maintenable.
Tester unitairement, rien de plus simple :
Un des avantages de l’utilisation de Redux toolkit est qu’il est possible de l’intégrer dans la base de code assez aisément. En effet, il suffit de remplacer les constants, actions, et reducers par des slices comme nous avons pu le voir, mais l’interface d’utilisation pour les composants reste globalement la même : un dispatch d’actions venant de slices plutôt que de fichiers contenant les actions directement.
Un des avantages de Redux toolkit est que la manière de tester ses reducers est similaire :
Lien GitHub : src/store/colors/slice.spec.ts
import colorsSlice, {ColorsState} from "./slice";
const { reducer, actions } = colorsSlice;
const { decreaseSaturation, increaseSaturation, selectColor } = actions;
describe('colors reducer', () => {
const initialState: ColorsState = {current: "Red", saturation: 50};
it.each`
color
${"Blue"}
${"Green"}
${"Red"}
`('should select the $color color', ({color}) => {
const nextState: ColorsState = reducer(initialState, selectColor(color));
expect(nextState).toStrictEqual({...initialState, current: color});
});
it('should not increase the saturation when it is already 100', () => {
const previousState: ColorsState = {current: "Red", saturation: 100};
const nextState: ColorsState = reducer(previousState, increaseSaturation());
expect(nextState).toStrictEqual(previousState);
});
it('should increase the saturation', () => {
const nextState: ColorsState = reducer(initialState, increaseSaturation());
expect(nextState).toStrictEqual({...initialState, saturation: 51});
});
it('should not decrease the saturation when it is already 0', () => {
const previousState: ColorsState = {current: "Red", saturation: 0};
const nextState: ColorsState = reducer(previousState, decreaseSaturation());
expect(nextState).toStrictEqual(previousState);
});
it('should decrease the saturation', () => {
const nextState: ColorsState = reducer(initialState, decreaseSaturation());
expect(nextState).toStrictEqual({...initialState, saturation: 49});
});
});
Pour aller plus loin
Nous avons pu voir dans cet article comment Redux toolkit permet de réduire le temps de développement ainsi que la verbosité d’un code faisant utilisation de Redux.
Bien entendu l’article n’est pas exhaustif et présente un des atouts de cette librairie qui en cache bien d’autres.
Aussi je vous recommande de vous renseigner sur la documentation officielle de Redux toolkit qui est plutôt détaillée et qui liste bien les différentes fonctionnalités tout en les présentant avec des exemples.