Immuabilité en JavaScript avec Redux

Publié: 2022-03-11

Dans un écosystème toujours croissant d'applications JavaScript riches et compliquées, il y a plus d'états à gérer que jamais : l'utilisateur actuel, la liste des publications chargées, etc.

Tout ensemble de données nécessitant un historique des événements peut être considéré comme avec état. La gestion de l'état peut être difficile et sujette aux erreurs, mais travailler avec des données immuables (plutôt que modifiables) et certaines technologies de support, à savoir Redux, aux fins de cet article, peut aider de manière significative.

Les données immuables ont des restrictions, à savoir qu'elles ne peuvent pas être modifiées une fois créées, mais elles présentent également de nombreux avantages, en particulier en termes d'égalité de référence par rapport à la valeur, ce qui peut considérablement accélérer les applications qui dépendent de la comparaison fréquente des données (vérifier si quelque chose doit être mis à jour , par exemple).

L'utilisation d'états immuables nous permet d'écrire du code qui peut rapidement dire si l'état a changé, sans avoir besoin de faire une comparaison récursive sur les données, ce qui est généralement beaucoup, beaucoup plus rapide.

Cet article couvrira les applications pratiques de Redux lors de la gestion d'état via des créateurs d'action, des fonctions pures, des réducteurs composés, des actions impures avec Redux-saga et Redux Thunk et, enfin, l'utilisation de Redux avec React. Cela dit, il existe de nombreuses alternatives à Redux, telles que les bibliothèques basées sur MobX, Relay et Flux.

Pourquoi Redux ?

L'aspect clé qui sépare Redux de la plupart des autres conteneurs d'états tels que MobX, Relay et la plupart des autres implémentations basées sur Flux est que Redux a un seul état qui ne peut être modifié que via des "actions" (objets JavaScript simples), qui sont envoyés au Magasin Redux. La plupart des autres magasins de données ont l'état contenu dans les composants React eux-mêmes, vous permettent d'avoir plusieurs magasins et/ou d'utiliser un état mutable.

Cela amène à son tour le réducteur du magasin, une fonction pure qui fonctionne sur des données immuables, à s'exécuter et potentiellement à mettre à jour l'état. Ce processus applique un flux de données unidirectionnel, qui est plus facile à comprendre et plus déterministe.

Le flux redux.

Étant donné que les réducteurs Redux sont de pures fonctions fonctionnant sur des données immuables, ils produisent toujours la même sortie avec la même entrée, ce qui les rend faciles à tester. Voici un exemple de réducteur :

 import Immutable from 'seamless-immutable' const initialState = Immutable([]) // create immutable array via seamless-immutable /** * a reducer takes a state (the current state) and an action object (a plain JavaScript object that was dispatched via dispatch(..) and potentially returns a new state. */ function addUserReducer(state = initialState, action) { if (action.type === 'USERS_ADD') { return state.concat(action.payload) } return state // note that a reducer MUST return a value } // somewhere else... store.dispatch({ type: 'USERS_ADD', payload: user }) // dispatch an action that causes the reducer to execute and add the user

Traiter des fonctions pures permet à Redux de prendre facilement en charge de nombreux cas d'utilisation qui ne sont généralement pas faciles à réaliser avec un état mutatif, tels que :

  • Voyage dans le temps (remonter le temps jusqu'à un état antérieur)
  • Journalisation (suivre chaque action pour déterminer ce qui a causé une mutation dans le magasin)
  • Environnements collaboratifs (tels que GoogleDocs, où les actions sont de simples objets JavaScript et peuvent être sérialisées, envoyées sur le réseau et rejouées sur une autre machine)
  • Rapport de bogue facile (il suffit d'envoyer la liste des actions envoyées et de les rejouer pour obtenir exactement le même état)
  • Rendu optimisé (au moins dans les frameworks qui rendent le DOM virtuel en fonction de l'état, comme React : en raison de l'immuabilité, vous pouvez facilement savoir si quelque chose a changé en comparant les références, par opposition à la comparaison récursive des objets)
  • Testez facilement vos réducteurs, car les fonctions pures peuvent facilement être testées à l'unité

Créateurs d'actions

Les créateurs d'action de Redux aident à garder le code propre et testable. N'oubliez pas que les «actions» dans Redux ne sont rien de plus que de simples objets JavaScript décrivant une mutation qui devrait se produire. Cela étant dit, écrire les mêmes objets encore et encore est répétitif et sujet aux erreurs.

Un créateur d'action dans Redux est simplement une fonction d'assistance qui renvoie un objet JavaScript simple décrivant une mutation. Cela permet de réduire le code répétitif et de conserver toutes vos actions au même endroit :

 export function usersFetched(users) { return { type: 'USERS_FETCHED', payload: users, } } export function usersFetchFailed(err) { return { type: 'USERS_FETCH_FAILED', payload: err, } } // reducer somewhere else... const initialState = Immutable([]) // create immutable array via seamless-immutable /** * a reducer takes a state (the current state) and an action object (a plain JavaScript object that was dispatched via dispatch(..) and potentially returns a new state. */ function usersFetchedReducer(state = initialState, action) { if (action.type === 'USERS_FETCHED') { return Immutable(action.payload) } return state // note that a reducer MUST return a value }

Utilisation de Redux avec des bibliothèques immuables

Alors que la nature même des réducteurs et des actions les rend faciles à tester, sans bibliothèque d'aide à l'immuabilité, rien ne vous protège des objets mutants, ce qui signifie que les tests de tous vos réducteurs doivent être particulièrement robustes.

Considérez l'exemple de code suivant d'un problème que vous rencontrerez sans bibliothèque pour vous protéger :

 const initialState = [] function addUserReducer(state = initialState, action) { if (action.type === 'USERS_ADD') { state.push(action.payload) // NOTE: mutating action!! return state } return state // note that a reducer MUST return a value }

Dans cet exemple de code, le voyage dans le temps sera interrompu car l'état précédent sera désormais le même que l'état actuel, les composants purs peuvent potentiellement ne pas être mis à jour (ou restitués) car la référence à l'état n'a pas changé même si les données qu'il contient a changé et les mutations sont beaucoup plus difficiles à comprendre.

Sans bibliothèque d'immuabilité, nous perdons tous les avantages fournis par Redux. Il est donc fortement recommandé d'utiliser une bibliothèque d'aide à l'immutabilité, telle que immutable.js ou seamless-immutable, en particulier lorsque vous travaillez dans une grande équipe avec plusieurs mains touchant le code.

Quelle que soit la bibliothèque que vous utilisez, Redux se comportera de la même manière. Comparons les avantages et les inconvénients des deux afin que vous puissiez choisir celui qui convient le mieux à votre cas d'utilisation :

Immuable.js

Immutable.js est une bibliothèque, construite par Facebook, avec un style plus fonctionnel sur les structures de données, telles que les cartes, les listes, les ensembles et les séquences. Sa bibliothèque de structures de données persistantes immuables effectue le moins de copie possible entre différents états.

Avantages:

  • Partage structurel
  • Plus efficace lors des mises à jour
  • Mémoire plus efficace
  • Possède une suite de méthodes d'assistance pour gérer les mises à jour

Les inconvénients:

  • Ne fonctionne pas de manière transparente avec les bibliothèques JS existantes (c'est-à-dire lodash, ramda)
  • Nécessite une conversion vers et depuis (toJS / fromJS), en particulier lors de l'hydratation / déshydratation et du rendu

Transparent-immuable

Seamless-immutable est une bibliothèque pour les données immuables qui est rétrocompatible jusqu'à ES5.

Il est basé sur les fonctions de définition de propriété ES5, telles que defineProperty(..) pour désactiver les mutations sur les objets. En tant que tel, il est entièrement compatible avec les bibliothèques existantes telles que lodash et Ramda. Il peut également être désactivé dans les versions de production, offrant un gain de performances potentiellement significatif.

Avantages:

  • Fonctionne de manière transparente avec les bibliothèques JS existantes (c'est-à-dire lodash, ramda)
  • Aucun code supplémentaire nécessaire pour prendre en charge la conversion
  • Les vérifications peuvent être désactivées dans les versions de production, ce qui augmente les performances

Les inconvénients:

  • Pas de partage structurel - les objets/tableaux sont copiés superficiellement, ce qui le rend plus lent pour les grands ensembles de données
  • Pas aussi efficace en mémoire

Redux et réducteurs multiples

Une autre fonctionnalité utile de Redux est la possibilité de composer des réducteurs ensemble. Cela vous permet de créer des applications beaucoup plus compliquées, et dans une application de toute taille appréciable, vous aurez inévitablement plusieurs types d'état (l'utilisateur actuel, la liste des publications chargées, etc). Redux supporte (et encourage) ce cas d'utilisation en fournissant naturellement la fonction combineReducers :

 import { combineReducers } from 'redux' import currentUserReducer from './currentUserReducer' import postsListReducer from './postsListReducer' export default combineReducers({ currentUser: currentUserReducer, postsList: postsListReducer, })

Avec le code ci-dessus, vous pouvez avoir un composant qui s'appuie sur currentUser et un autre composant qui s'appuie sur postsList . Cela améliore également les performances car chaque composant ne s'abonnera qu'à la ou aux branches de l'arborescence qui le concerne.

Actions impures dans Redux

Par défaut, vous ne pouvez envoyer que des objets JavaScript simples à Redux. Avec le middleware, cependant, Redux peut prendre en charge des actions impures telles que l'obtention de l'heure actuelle, l'exécution d'une requête réseau, l'écriture d'un fichier sur le disque, etc.

'Middleware' est le terme utilisé pour les fonctions qui peuvent intercepter les actions envoyées. Une fois intercepté, il peut faire des choses comme transformer l'action ou envoyer une action asynchrone, un peu comme le middleware dans d'autres frameworks (comme Express.js).

Deux bibliothèques middleware très courantes sont Redux Thunk et Redux-saga. Redux Thunk est écrit dans un style impératif, tandis que Redux-saga est écrit dans un style fonctionnel. Comparons les deux.

Redux Thunk

Redux Thunk prend en charge les actions impures dans Redux en utilisant des thunks, des fonctions qui renvoient d'autres fonctions pouvant être chaînées. Pour utiliser Redux-Thunk, vous devez d'abord monter le middleware Redux Thunk sur le magasin :

 import { createStore, applyMiddleware } from 'redux' import thunk from 'redux-thunk' const store = createStore( myRootReducer, applyMiddleware(thunk), // here, we apply the thunk middleware to R )

Nous pouvons maintenant effectuer des actions impures (comme effectuer un appel d'API) en envoyant un thunk au magasin Redux :

 store.dispatch( dispatch => { return api.fetchUsers() .then(users => dispatch(usersFetched(users)) // usersFetched is a function that returns a plain JavaScript object (Action) .catch(err => dispatch(usersFetchError(err)) // same with usersFetchError } )

Il est important de noter que l'utilisation de thunks peut rendre votre code difficile à tester et rendre plus difficile le raisonnement dans le flux de code.

Redux-saga

Redux-saga prend en charge les actions impures via une fonctionnalité ES6 (ES2015) appelée générateurs et une bibliothèque d'assistants fonctionnels / purs. L'avantage des générateurs est qu'ils peuvent être repris et mis en pause, et leur contrat API les rend extrêmement faciles à tester.

Voyons comment nous pouvons améliorer la lisibilité et la testabilité de la méthode thunk précédente en utilisant des sagas !

Tout d'abord, montons le middleware Redux-saga dans notre magasin :

 import { createStore, applyMiddleware } from 'redux' import createSagaMiddleware from 'redux-saga' import rootReducer from './rootReducer' import rootSaga from './rootSaga' // create the saga middleware const sagaMiddleware = createSagaMiddleware() // mount the middleware to the store const store = createStore( rootReducer, applyMiddleware(sagaMiddleware), ) // run our saga! sagaMiddleware.run(rootSaga)

Notez que la run(..) doit être appelée avec la saga pour que son exécution commence.

Créons maintenant notre saga :

 import { call, put, takeEvery } from 'redux-saga/effects' // these are saga effects we'll use export function *fetchUsers(action) { try { const users = yield call(api.fetchUsers) yield put(usersFetched(users)) } catch (err) { yield put(usersFetchFailed(err)) } } export default function *rootSaga() { yield takeEvery('USERS_FETCH', fetchUsers) }

Nous avons défini deux fonctions génératrices, une qui récupère la liste des utilisateurs et la rootSaga . Notez que nous n'avons pas appelé api.fetchUsers directement, mais que nous l'avons renvoyé dans un objet d'appel. En effet, Redux-saga intercepte l'objet d'appel et exécute la fonction qu'il contient pour créer un environnement pur (en ce qui concerne vos générateurs).

rootSaga donne un seul appel à une fonction appelée takeEvery, qui prend chaque action envoyée avec un type de USERS_FETCH et appelle la saga fetchUsers avec l'action qu'elle a prise. Comme nous pouvons le voir, cela crée un modèle d'effets secondaires très prévisible pour Redux, ce qui le rend facile à tester !

Tester les sagas

Voyons comment les générateurs rendent nos sagas faciles à tester. Nous utiliserons moka dans cette partie pour exécuter nos tests unitaires et chai pour les assertions.

Parce que les sagas produisent des objets JavaScript simples et sont exécutées dans un générateur, nous pouvons facilement tester qu'elles exécutent le bon comportement sans aucune simulation du tout ! Gardez à l'esprit que call , take , put , etc. ne sont que de simples objets JavaScript qui sont interceptés par le middleware Redux-saga.

 import { take, call } from 'redux-saga/effects' import { expect } from 'chai' import { rootSaga, fetchUsers } from '../rootSaga' describe('saga unit test', () => { it('should take every USERS_FETCH action', () => { const gen = rootSaga() // create our generator iterable expect(gen.next().value).to.be.eql(take('USERS_FETCH')) // assert the yield block does have the expected value expect(gen.next().done).to.be.equal(false) // assert that the generator loops infinitely }) it('should fetch the users if successful', () => { const gen = fetchUsers() expect(gen.next().value).to.be.eql(call(api.fetchUsers)) // expect that the call effect was yielded const users = [ user1, user2 ] // some mock response expect(gen.next(users).value).to.be.eql(put(usersFetched(users)) }) it('should fail if API fails', () => { const gen = fetchUsers() expect(gen.next().value).to.be.eql(call(api.fetchUsers)) // expect that the call effect was yielded const err = { message: 'authentication failed' } // some mock error expect(gen.throw(err).value).to.be.eql(put(usersFetchFailed(err)) }) })

Travailler avec React

Bien que Redux ne soit lié à aucune bibliothèque complémentaire spécifique, il fonctionne particulièrement bien avec React.js puisque les composants React sont des fonctions pures qui prennent un état en entrée et produisent un DOM virtuel en sortie.

React-Redux est une bibliothèque d'assistance pour React et Redux qui élimine la majeure partie du travail acharné reliant les deux. Pour utiliser React-Redux le plus efficacement possible, passons en revue la notion de composants de présentation et de composants de conteneur.

Les composants de présentation décrivent à quoi les choses doivent ressembler visuellement, en fonction uniquement de leurs accessoires à rendre ; ils invoquent des rappels à partir d'accessoires pour répartir les actions. Ils sont écrits à la main, complètement purs et ne sont pas liés à des systèmes de gestion d'état comme Redux.

Les composants de conteneur, d'autre part, décrivent comment les choses devraient fonctionner, sont conscients de Redux, envoient des actions Redux directement pour effectuer des mutations et sont généralement générés par React-Redux. Ils sont souvent associés à un composant de présentation, fournissant ses accessoires.

Composants de présentation et composants de conteneur dans Redux.

Écrivons un composant de présentation et connectons-le à Redux via React-Redux :

 const HelloWorld = ({ count, onButtonClicked }) => ( <div> <span>Hello! You've clicked the button {count} times!</span> <button onClick={onButtonClicked}>Click me</button> </div> ) HelloWorld.propTypes = { count: PropTypes.number.isRequired, onButtonClicked: PropTypes.func.isRequired, }

Notez qu'il s'agit d'un composant "stupide" qui repose entièrement sur ses accessoires pour fonctionner. C'est formidable, car cela rend le composant React facile à tester et facile à composer. Voyons maintenant comment connecter ce composant à Redux, mais voyons d'abord ce qu'est un composant d'ordre supérieur.

Composants d'ordre supérieur

React-Redux fournit une fonction d'assistance appelée connect( .. ) qui crée un composant d'ordre supérieur à partir d'un composant React "stupide" conscient de Redux.

React met l'accent sur l'extensibilité et la réutilisation grâce à la composition, c'est-à-dire lorsque vous enveloppez des composants dans d'autres composants. L'encapsulation de ces composants peut modifier leur comportement ou ajouter de nouvelles fonctionnalités. Voyons comment nous pouvons créer un composant d'ordre supérieur à partir de notre composant de présentation qui est conscient de Redux - un composant de conteneur.

Voici comment procéder :

 import { connect } from 'react-redux' const mapStateToProps = state => { // state is the state of our store // return the props that we want to use for our component return { count: state.count, } } const mapDispatchToProps = dispatch => { // dispatch is our store dispatch function // return the props that we want to use for our component return { onButtonClicked: () => { dispatch({ type: 'BUTTON_CLICKED' }) }, } } // create our enhancer function const enhancer = connect(mapStateToProps, mapDispatchToProps) // wrap our "dumb" component with the enhancer const HelloWorldContainer = enhancer(HelloWorld) // and finally we export it export default HelloWorldContainer

Notez que nous avons défini deux fonctions, mapStateToProps et mapDispatchToProps .

mapStateToProps est une fonction pure de (état : objet) qui renvoie un objet calculé à partir de l'état Redux. Cet objet sera fusionné avec les accessoires passés au composant enveloppé. Ceci est également connu sous le nom de sélecteur, car il sélectionne des parties de l'état Redux à fusionner dans les accessoires du composant.

mapDispatchToProps est également une fonction pure, mais l'une des (répartition : (Action) => void) qui renvoie un objet calculé à partir de la fonction de répartition Redux. Cet objet sera également fusionné avec les accessoires passés au composant enveloppé.

Maintenant, pour utiliser notre composant conteneur, nous devons utiliser le composant Provider dans React-Redux pour indiquer au composant conteneur quel magasin utiliser :

 import { Provider } from 'react-redux' import { render } from 'react-dom' import store from './store' // where ever your Redux store resides import HelloWorld from './HelloWorld' render( ( <Provider store={store}> <HelloWorld /> </Provider> ), document.getElementById('container') )

Le composant Provider propage le magasin vers tous les composants enfants qui s'abonnent au magasin Redux, en gardant tout au même endroit et en réduisant les points d'erreur ou de mutation !

Renforcez la confiance dans le code avec Redux

Avec cette nouvelle connaissance de Redux, ses nombreuses bibliothèques de support et sa connexion de cadre avec React.js, vous pouvez facilement limiter le nombre de mutations dans votre application grâce au contrôle d'état. Un contrôle d'état fort, à son tour, vous permet d'aller plus vite et de créer une base de code solide avec plus de confiance.