#CestFacile : Redux & Immutable JS – (Grosse) Introduction

On utilise déjà Redux pour gérer le state au sein de notre application. Lorsque l’on passe dans nos reducers, on retourne une nouvelle version de notre state, grâce aux spread operators {..state, {}}, ce qui permet de s’assurer qu’on utilise toujours des objets non pas altérés, mais une nouvelle version du state avec les changements que l’on souhaite effectués.

Le fait de procéder ainsi rend notre state plus prévisible – on prend un state en entrée, on en retourne un nouveau en sortie, et ça nous permet de débugger une application nettement plus facilement, notamment grâce aux devtools et à Reactotron qu’on découvrira rapidement dans un autre post.

On a donc différentes versions de notre state, que l’on peut comparer, rejouer dans nos outils de développement… et on peut même mettre en place cette feature pour l’utilisateur au sein de l’UI (le bon vieux undo / redo sacrément galère à implémenter dans une application web mais qui est trivial dans une application React / Redux).

Bref, tout ça c’est bien cool, mais on peut aller plus loin et tirer profit à fond de l’immutabilité en utilisant la librairie  Immutable JS.

Pour vous convaincre de l’utilité d’Immutable, je vous recommande ces articles :

Une fois que vous n’aurez plus aucun doute, on pourra passer aux choses sérieuses.

On commence par installer immutable :

yarn add immutable

Première approche : Ciblé

Première approche, uniquement au niveau des Reducers :

import Immutable from 'immutable'
import * as types from './actionTypes'
import initialState from '../../Store/initialState'

let Auth = (state = initialState.auth, action) => {
 state = Immutable.fromJS(state)

 switch (action.type){
   case types.LOGIN:
     return state
       .set('loading', true)
       .set('logged', false)
       .toJS()
     // Au lieu de :  
     // return {...state, loading: true, logged: false};
 
   case types.LOGGED_IN:
     return state
       .set('loading', false)
       .set('logged', true)
       .toJS()
     // Au lieu de : 
     // return {...state, logged: true, loading: false};
 
   default:
     return state.toJS();
   }
}
export default Auth;

Dans ./src/Services/Auth/reducer.js

On converti notre objet initialState avec Immutable (fromJS()), on travaille dessus en modifiant les propriétés (set()) – ce qui nous assure de retourner une nouvelle version du state non muté. En bout de course on restitue un objet JS (toJS()) dans le fonctionnement classique de React / Redux.

Ca nous permet de lever une erreur lorsqu’on lance nos tests unitaires :

On fix :

import Auth from './reducer'
import * as types from './actionTypes'

describe('auth reducer', () => {
 it('should return the initial state', () => {
 // On passe undefined pour le state car on a une valeur par défaut dans notre reducer
 expect(Auth(undefined, {})).toEqual({"loading": false, "logged": false})
 });

it('should handle login action', () => {
 expect(Auth(undefined, {
 type: types.LOGIN,
 })).toEqual({loading: true, logged: false})
 });

it('should handle logged in action', () => {
 expect(Auth(undefined, {
 type: types.LOGGED_IN,
 })).toEqual({loading: false, logged: true})
})
})

Dans ./src/Services/Auth/reducer.test.js

Le code :

https://github.com/GregoryBabonaux/react-c-est-facile/tree/a495abea051a911c43cf9352a7dee6ab0b9d7b1d

A ce stade on a juste ajouté un tout petit peu des concepts de l’immutabilité sans que ça impact lourdement le code de notre application. Dans pas mal de cas, ça peut suffire, mais ce serait dommage de s’arrêter en si bon chemin.

On va donc faire en sorte que l’ensemble du state soit immutable.

Deuxième approche : Root Reducer

Vous êtes complètement convaincus par l’intérêt d’Immutable, du coup vous voulez généraliser son utilisation partout dans votre application.

On commence par ajouter redux-immutable :

yarn add redux-immutable

On va devoir modifier notre initialState et notre rootReducer :

import Immutable from 'immutable';

const initialState = Immutable.fromJS({
 stuff: [],
 auth: {
   loading: false,
   logged: false,
 }
})
export default initialState

Dans ./src/Store/initialState.js

On transforme notre initialState avec Immutable de façon à retourner – non plus un simple objet, mais un objet immutable-js. Ce qui nous donne accès aux méthodes d’Immutable.

Dans notre rootReducer, on a juste à remplacer combineReducers :

import {combineReducers} from 'redux-immutable'
import stuff from '../Services/Stuff/reducer'
import auth from '../Services/Auth/reducer'


const rootReducer = combineReducers({
 stuff,
 auth
})

export default rootReducer

Mais on a encore quelques modifications à faire :

Au niveau des reducers

Notre Auth reducer ressemble désormais à ceci :

import * as types from './actionTypes'
import initialState from '../../Store/initialState'

let Auth = (state = initialState.get('auth'), action) => {

switch (action.type){
 case types.LOGIN:
   return state
     .set('loading', true)
     .set('logged', false);

case types.LOGGED_IN:
 return state
   .set('loading', false)
   .set('logged', true);
 
 default:
   return state;
 }
}

export default Auth;

Dans ./src/Services/Auth/reducer.js

On a viré l’import d’immutable, car on gère tout au niveau du rootReducer désormais.

On a plus besoin non plus de convertir notre objet en objet immutable (fromJS) puis de le renvoyer en bout de course avec toJS. C’est au niveau des composants que l’on va gérer ça.

Pour notre reducer Stuff, on a une action qui doit push une nouvelle entrée dans notre tableau.

On l’ajoutera de cette façon :

import * as types from './actionTypes'
import initialState from '../../Store/initialState'

let stuff = (state = initialState.get('stuff'), action) => {
   switch(action.type){
     case types.DO_SOMETHING:
       return state.set(state.size, action.something)
     default:
       return state
   }
}
export default stuff;

Au niveau des composants

Dans chacun mapStateToProps, on va devoir passer nos objets Immutable en objets « plain js » :

const mapStateToProps = (state) => ({
  auth : state.toJS().auth
})

C’est tout ?

Et bah non.

Dans nos exemples, on utilise assez massivement fromJS() et toJS() pour convertir notre state en objet immutable puis en le restituant en plain JS. Vous vous en doutez, tout ça à un coût en terme de performances.

La première approche est la plus lourde car dès que l’on dispatch une action, le reducer va manipuler notre objet, ça peut devenir un soucis énorme dès que vous commencez  à créer de plus en plus de reducers, ou si vous dispatchez des tonnes d’actions.

Dans la seconde approche, c’est au niveau des containers que ça pose soucis, on mapStateToProps un objet immutable en objet plain JS. Ca peut faire tout aussi mal.

Dans l’idéal, on évitera tout ça autant que possible, mais comment faire ?

Simplement en passant à nos containers nos objets immutable et en gérant cet aspect directement.

const mapStateToProps = (state) => ({
  auth : state.get('auth')
})

Du coup on pourrait récupérer nos props avec un :

this.props.auth.get('loading')

Mais on a un nouveau soucis, cette fois avec nos prop-types :

 auth: PropTypes.shape({
  loading: PropTypes.bool.isRequired,
  logged: PropTypes.bool.isRequired,
 }),

Va vous balancer une bonne vieille erreur dans la console :

So what ?

Pour simplifier tout ça, on va utiliser une nouvelle dépendance, react-immutable-proptypes, qui va se charger de tout ça.

yarn add react-immutable-proptypes

On importe dans nos composants :

import ImmutablePropTypes from 'react-immutable-proptypes';

Puis il suffit ensuite de remplacer nos shape() par un ImmutablePropTypes.contains() :

 auth: ImmutablePropTypes.contains({
  loading: PropTypes.bool.isRequired,
  logged: PropTypes.bool.isRequired,
 }),

Shazam !

Mais…

Nos tests sont flingués, et pas qu’un peu :

Dans la mesure où l’on a choisi la seconde approche, nos reducers renvoient désormais un objet Immutable et non plus un objet plain JS, on doit le prendre en compte dans nos tests :

import Auth from './reducer'
import * as types from './actionTypes'
import Immutable from 'immutable'

describe('auth reducer', () => {
 it('should return the initial state', () => {
   // On passe undefined pour le state car on a une valeur par défaut dans notre reducer
   expect(Auth(undefined, {})).toEqual(Immutable.Map({"loading": false, "logged": false}))
 });

 it('should handle login action', () => {
   expect(Auth(undefined, {
     type: types.LOGIN,
   })).toEqual(Immutable.Map({loading: true, logged: false}))
 });

 it('should handle logged in action', () => {
   expect(Auth(undefined, {
     type: types.LOGGED_IN,
   })).toEqual(Immutable.Map({loading: false, logged: true}))
 })
})

#CaCestFait

Dans Login.test.js, on créait un nouveau state à partir de l’initialState, avec un loading à true pour tester la présence d’un spinner en cas de chargement en cours.

L’initialState étant désormais un Map Immutable, On doit modifier un tout petit peu cette partie en utilisant setIn :

 it('should render a spinner if loading is true', () => {
   const newState = initialState.setIn(['auth', 'loading'], true)
   const newStore = createMockStore(newState)
   const wrapper = mount(<Login store={newStore} />)
 
   expect(wrapper.find('Form')).to.have.length(0)
   expect(wrapper.find('Loader')).to.have.length(1)
 })

Back to business !

Le code :

https://github.com/GregoryBabonaux/react-c-est-facile/tree/28c122b660ac67de58675a655db61a2eed2d4ea4

Dans un prochain post, on parlera des Records 🙂

Laisser un commentaire

Votre adresse de messagerie ne sera pas publiée. Les champs obligatoires sont indiqués avec *