Si on découvrait Redux Saga ?

Redux, est-ce que j’ai vraiment besoin d’en parler ou bien est-ce que ca fait chboum la dedans ?

Un projet React sans Redux (ou sans Flux, ou Reflux, ou Fluxxor, ou Mobx), c’est un peu comme essayer de trouver Charlie dans une convention de gens qui s’appellent tous Charlie. Ou trouver une allumette  dans une boite de Smarties. Ou chercher un survivant dans le crash d’un Boeing 747. Ou Netflix sans House Of Cards. Ou AMC sans Walking Dead. Ou TF1 sans Mimie Mathy. Ou France 2 sans Pujada…. ooooooooops

Aujourd’hui, on va s’intéresser à un moyen de rendre notre state encore plus prévisible avec Redux Saga. Vous allez voir, c’est simple, efficace et ça rendra votre code tellement plus beau. Vos seins vont pousser, surtout si vous êtes un homme et buvez de la bière. Et vous serez plus fertile, surtout si vous êtes une femme et buvez de la bière.

Let’s go

Vous connaissez surement Redux thunk.

Si si.

Vous connaissez sûrement.

Un Thunk permet notamment de lancer des tâches asynchrones. Enfin, c’est pas tout à fait ça car les dispatch envoyés depuis un middleware sont synchrones, mais pas leur réponse si par exemple il s’agit d’appels Ajax, mais ça peut globalement être tout ce qui est asynchrone en général, comme une tâche de fond.

Ca ressemble à ça :


  export function monaction(payload){
    return (dispatch){
      dispatch(mangeca1(payload))
      dispatch(mangeca2(payload))
    }
  }

C’est très pratique, mais ça a un coût : vous ne maitrisez pas ce qui se passe exactement dans le state de votre application. C’est précisément ce qu’on veut éviter avec Redux qui est, de base, une façon plutôt simple de gérer un state prévisible.

So what ?

Les SAGAS ajoutent une notion prévisible dans leur exécution, grâce aux générateurs qui vont vous permettre d’écouter des évènements (des actions).

Quand vous faites un appel AJAX, il y a plein de choses que vous ne maitrisez pas et qui ont des conséquences sur le state, notamment le fait que l’appel peut prendre beaucoup de temps et que l’utilisateur peut déclencher d’autres actions entre le moment où l’appel est lancé et où l’appel reçoit une réponse. Peut être que, parmi la quantité d’appels qu’il envoi au même webservice, seul la dernière réponse l’intéresse (pour suivre un hashtag sur Twitter par exemple).

Tout ça – et bien plus, on peut le gérer avec les Sagas.

On ne va pas passer la doc en revue ou faire un cours magistral à propos des générateurs, mais simplement mettre rapidement en pratique un cas assez simple. Libre à vous d’aller plus loin en consultant la documentation.

Admettons que vous avez un projet structuré comme ça :

  • actions/users.js
  • reducers/users.js

Au sein de actions/users, vous dispatcher une action comme ça :

export function login(payload){
  return {
    type: LOGIN,
    payload
  }
}

Vous aimeriez pouvoir lancer d’autres actions si l’utilisateur arrive à se logger (par exemple en stockant un JWT en localstorage, puis en redirigeant l’utilisateur sur son dashboard), ou d’autres si la tentative de login a échouée (afficher un message d’erreur, afficher un captcha au bout de 3 tentatives échouées…).

Old school

Vous pourriez très bien vous débrouiller sans les Sagas, uniquement avec les Thunks, mais ça rendrait vos actions crades et, surtout, impures, sans parler des éventuels effets de bord.



export function login(payload){
  return (dispatch) => {
    fetch(endPoint, {
        method  : 'POST',
        headers : {}
        body: {}
      })
      .then(statusHelper)
      .then(response => response.json())
      .catch( error => {
        dispatch(loginError(error)
      })
      .then(data => {
          dispatch(loginSuccess(data))
       })
  }
}

Nan, tout ça c’est pas terrible.

La même chose avec les Sagas

On a toujours besoin de l’action type LOGIN, on revient donc à la première solution :

export function login(payload){
  return {
    type: LOGIN,
    payload
  }
}

Notre function est pure, tout le monde est content.

Désormais ce sont les SAGAS qui vont écouter le dispatch de l’action LOGIN, voici une version simplifiée sans l’implémentation de l’effect combinator Race (vous devriez tout de même l’utiliser pour gérer la concurrence entre l’action de Login et l’action de Logout) :


import {takeEvery, call, put} from 'redux-sagas/effects' // Les effects creators dont on a besoin pour écouter, appeler et renvoyer quelque chose, cf la doc :) 
import authApi from '../endpoints/authApi' // Avec un bon vieux fetch des familles 
import {loginSuccess, loginError} from '../actions/users'

export function* authorize(action){
  try{
    const loginResult = yield call(authApi.login, action);
    yield put(loginSuccess(loginResult))
  }
  catch(error){
    yield put(loginError(error))
  }
}

export function* watchLoginFlow(){
 yield takeEvery('LOGIN', authorize)
}

export default watchLoginFlow;

Notre authApi pour info :

const authApi = {

  login(action){
    return fetch(authEndpoint, {
        method  : 'POST',
        headers : {
          'Accept': 'application/json',
          'Content-Type': 'application/json'
        },
        body: JSON.stringify({username: action.user, password: action.password})
      })
      .then(statusHelper)
      .then(response => response.json())
      .then(data => {
          return data
       })
  },
}

export default authApi

Notez qu’on catch déjà dans la saga, on ne catch donc pas dans l’appel de l’API, ce serait de la gourmandise et ça enlèverait tout le charme des Sagas et des puts qu’on voudrait potentiellement mettre en place en cas de loginError.

Le plus beau dans tout ça, c’est qu’on pourrait coller tout plein d’autres put pour dispatcher d’autres actions, elles s’exécuteraient les unes à la suite des autres grâce aux générateurs.

Ca peut aller nettement plus loin

Je parlais de Race tout à l’heure, qui est un effect combinator. L’idée derrière Race est de définir un winner :

let request = yield take('LOGIN_REQUEST')
let winner = yield race({
  auth: call(authorize, request),
  logout: take('LOGOUT')
})

On peut ensuite travailler avec le winner pour définir la stratégie à employer. Si le winner est auth, on fait un call à l’api pour authentifier, si le winner est logout, on travaille sur la déconnexion. C’est très bien expliqué dans… ouaip… la documentation.

Bref

En deux mots, vu le gain apporté par Redux-Sagas, que ce soit en terme de lisibilité dans le code, ou (surtout) en prévention des effets de bord, ce serait dommage de s’en privé. Mais… comment fonctionnent les Sagas au sein d’une application React / Redux ?

C’est plutôt simple en fait, comme un middleware :

Vous avez juste à modifier la configuration de votre store en y insérant votre middleware, sans oublier de définir de le run, et roule ma poule :

import { createStore, compose, applyMiddleware } from 'redux';
import thunk from 'redux-thunk';
import createSagaMiddleware from 'redux-saga'
import rootReducer from '../reducers';

import rootSaga from '../sagas'
const sagaMiddleware = createSagaMiddleware(rootSaga)

export default function configureStore(initialState) {
  const store = createStore(
    rootReducer,
    initialState,
    compose (
      applyMiddleware(thunk),
      applyMiddleware(sagaMiddleware),
    )
  );
  sagaMiddleware.run(rootSaga) // IMPORTANT !!!
  return store;
}

Votre RootSaga pourrait ressembler à ça :

import {fork} from 'redux-saga/effects'
import watchLoginFlow from './login'

const sagas = [
  watchLoginFlow,
]

function* rootSaga() {
  yield sagas.map(saga => fork(saga)); 
}
export default rootSaga

Bisous !

3 commentaires Ajoutez le votre

  1. Jérémie dit :

    merci, c’est très précis. Je note juste que Pujadas s’est fait viré de France 2 entre temps

Laisser un commentaire

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