Skip to content

Latest commit

 

History

History
663 lines (465 loc) · 24.8 KB

05-redux-immutable-fetch.md

File metadata and controls

663 lines (465 loc) · 24.8 KB

05 - Redux, Immutable et Fetch

Le code pour ce chapitre est disponible ici.

Dans ce chapitre, nous allons coupler React et Redux pour faire une app très simple. Cette app contiendra un message et un bouton. Le message changera lorsque l'utilisateur clique sur le bouton.

Avant de commencer, voici une rapide introduction à ImmutableJS, qui n'a absolument rien à voir avec React et Redux mais que nous utiliserons dans ce chapitre.

ImmutableJS

💡 ImmutableJS (ou Immutable) est une bibliothèque Facebook qui sert à manipuler des collections immuables (immutable 🇬🇧), comme les listes ou les maps. N'importe quel changement effectué sur une collection immuable retourne un nouvel objet sans transformer l'objet original.

Par exemple, au lieu de faire :

const obj = { a: 1 }
obj.a = 2 // Mute `obj`

Vous feriez

const obj = Immutable.Map({ a: 1 })
obj.set('a', 2) // Retourne un nouvel object sans muter `obj`

Cette approche suit le paradigme de la programmation fonctionnelle qui fonctionne très bien avec Redux. 👌

Quand on crée des collections immuables, la méthode Immutable.fromJS() s'avère être très pratique : elle prend n'importe quel objet/tableau JS et retourne une version immuable de celui-ci :

const immutablePerson = Immutable.fromJS({
  name: 'Stan',
  friends: ['Kyle', 'Cartman', 'Kenny'],
})

console.log(immutablePerson)

/*
 *  Map {
 *    "name": "Stan",
 *    "friends": List [ "Kyle", "Cartman", "Kenny" ]
 *  }
 */

Redux

💡 Redux est une bibliothèque qui va prendre en charge les cycles de vie de notre application. Redux crée un store, qui est la seule source de vérité à propos du state (l'état 🇫🇷) de votre app à n'importe quel moment.

Commençons par la partie simple, déclarons nos actions Redux :

  • Lancez yarn add redux redux-actions

  • Créez un fichier src/client/action/hello.js qui contient :

// @flow

import { createAction } from 'redux-actions'

export const SAY_HELLO = 'SAY_HELLO'

export const sayHello = createAction(SAY_HELLO)

Ce fichier expose une action, SAY_HELLO, et son action creator (créateur d'action 🇫🇷), sayHello, qui est une fonction. Nous utilisons redux-actions pour réduire le boilerplate associés aux actions Redux. redux-actions implémente le modèle de standard d'action Flux , qui fait que les action creators retournent des objets ayant les attributs type et payload.

  • Créez une fichier src/client/reducer/hello.js qui contient :
// @flow

import Immutable from 'immutable'
import type { fromJS as Immut } from 'immutable'

import { SAY_HELLO } from '../action/hello'

const initialState = Immutable.fromJS({
  message: 'Initial reducer message',
})

const helloReducer = (state: Immut = initialState, action: { type: string, payload: any }) => {
  switch (action.type) {
    case SAY_HELLO:
      return state.set('message', action.payload)
    default:
      return state
  }
}

export default helloReducer

Dans ce fichier, on initialise le state (l'état 🇫🇷) de notre reducer (réducteur 🇫🇷) avec un map immuable contenant une propriété, message, initialisé à Initial reducer message. Le helloReducer gère l'action SAY_HELLO en initialisant le nouveau message avec le payload (la charge 🇫🇷) de l'action. L'annotation Flow pour action la déstructure en un type et un payload. Le payload peut être de n'importe quel type. Ca peut avoir l'air assez funky si c'est la première fois que vous voyez ça, mais ça reste assez compréhensible. Pour le type de state, on utilise l'instruction Flow import typepour obtenir le type retourné de fromJS. On le renomme Immut pour plus de clareté, car state: fromJS serait assez confus. La ligne import type sera retirée comme n'importe quelle autre annotation Flow. Remarquez l'utilisation de Immutable.fromJS() et set() comme nous avons pu les voir précédemment.

React-Redux

💡 react-redux connecte un store Redux avec des composants React. Avec react-redux, quand le store Redux change, les composants React sont modifiés automatiquement. Ils déclenchent aussi les actions Redux.

  • Lancez yarn add react-redux

Dans ces sections, nous allons créer des Components (composants 🇫🇷) des Containers (conteneurs 🇫🇷).

Les Components sont des composants React stupides, dans le sens où ils ne savent rien du state Redux (l'état de Redux 🇫🇷). Les Containers sont des composants intelligents qui connaissent le state et que nous allons connecter à nos composants stupides.

  • Créez un fichier src/client/component/button.jsx contenant:
// @flow

import React from 'react'

type Props = {
  label: string,
  handleClick: Function,
}

const Button = ({ label, handleClick }: Props) =>
  <button onClick={handleClick}>{label}</button>

export default Button

Remarque: Vous pouvez voir un cas de type alias (alias de type 🇫🇷) Flow ici. Nous définissons le type de Props avant d'annoter les props déstructurées de notre composant avec.

  • Créez un fichier src/client/component/message.jsx contenant :
// @flow

import React from 'react'

type Props = {
  message: string,
}

const Message = ({ message }: Props) =>
  <p>{message}</p>

export default Message

Voici des exemples de composants stupides. Ils n'ont pas de logique et affichent juste ce qu'on leur dit d'afficher via les React props. La principale différence entre button.jsx et message.jsx est que Button contient une référence à un action dispatcher (expéditeur d'action 🇫🇷) dans ses props, où Message contient juste quelques données à afficher.

Encore une fois, les components ne savent rien des actions Redux ou sur le state de notre app. C'est pourquoi nous allons créer des containers intelligents qui alimenterons les bons action dispatchers et les bonnes données à ces composants stupides.

  • Créez un fichier src/client/container/hello-button.js contenant :
// @flow

import { connect } from 'react-redux'

import { sayHello } from '../action/hello'
import Button from '../component/button'

const mapStateToProps = () => ({
  label: 'Say hello',
})

const mapDispatchToProps = dispatch => ({
  handleClick: () => { dispatch(sayHello('Hello!')) },
})

export default connect(mapStateToProps, mapDispatchToProps)(Button)

Ce container relie le composant Button avec l'action sayHello et la méthode dispatch de Redux.

  • Créez un fichier src/client/container/message.js contenant :
// @flow

import { connect } from 'react-redux'

import Message from '../component/message'

const mapStateToProps = state => ({
  message: state.hello.get('message'),
})

export default connect(mapStateToProps)(Message)

Ce container relie le state de l'app Redux avec le composant Message. Quand le state change, Message ré-affichera automatique le bon prop (propriété 🇫🇷) message . Ces connexions sont faites via la fonction connect de react-redux.

  • Modifier votre fichier src/client/app.jsx de la manière suivante :
// @flow

import React from 'react'
import HelloButton from './container/hello-button'
import Message from './container/message'
import { APP_NAME } from '../shared/config'

const App = () =>
  <div>
    <h1>{APP_NAME}</h1>
    <Message />
    <HelloButton />
  </div>

export default App

Nous n'avons toujours pas initialisé le store Redux et n'avons pas encore mis ces deux containers dans notre app :

  • Éditez le fichier src/client/index.jsx comme ceci :
// @flow

import 'babel-polyfill'

import React from 'react'
import ReactDOM from 'react-dom'
import { AppContainer } from 'react-hot-loader'
import { Provider } from 'react-redux'
import { createStore, combineReducers } from 'redux'

import App from './app'
import helloReducer from './reducer/hello'
import { APP_CONTAINER_SELECTOR } from '../shared/config'
import { isProd } from '../shared/util'

const store = createStore(combineReducers({ hello: helloReducer }),
  // eslint-disable-next-line no-underscore-dangle
  isProd ? undefined : window.__REDUX_DEVTOOLS_EXTENSION__ && window.__REDUX_DEVTOOLS_EXTENSION__())

const rootEl = document.querySelector(APP_CONTAINER_SELECTOR)

const wrapApp = (AppComponent, reduxStore) =>
  <Provider store={reduxStore}>
    <AppContainer>
      <AppComponent />
    </AppContainer>
  </Provider>

ReactDOM.render(wrapApp(App, store), rootEl)

if (module.hot) {
  // flow-disable-next-line
  module.hot.accept('./app', () => {
    // eslint-disable-next-line global-require
    const NextApp = require('./app').default
    ReactDOM.render(wrapApp(NextApp, store), rootEl)
  })
}

Prenons un instant pour revoir tout ça. D'abord, nous créons un store avec createStore. Les stores sont créés en leur passant des reducers. Ici, nous n'avons qu'un seul reducer, mais pour le bien de notre évolutivité future, nous utilisons combineReducers pour regrouper tous nos reducers ensemble. Le dernier paramètre bizarre de createStore est un truc pour relier Redux aux outils de développement (Redux Devtools 🇬🇧) du navigateur, qui sont incroyablement pratiques pour débugger. Puisque ESLint va se plaindre des underscores dans __REDUX_DEVTOOLS_EXTENSION__, on désactive cette règle. Ensuite, on emballe toute notre app dans le composant Provider de react-redux' grâce à notre fonction wrapApp, et lui passons notre store.

🏁 Maintenant, vous pouvez lancer yarn start et yarn dev:wds et vous rendre sur http://localhost:8000. Vous devriez voir s'afficher "Initial reducer message" et un bouton. Quand vous cliquez sur le bouton, le message devrait changer pour "Hello!". Si vous avez installé les outils de développement Redux dans votre navigateur, vous devriez voir le state de votre app changer au fur et à mesure que vous cliquez sur le bouton.

Félicitations, nous avons enfin créé une app qui fait quelque chose 🎉 👏 ! Bon d'accord, ce n'est pas super impressionnant de l'extérieur, mais on sait tous que c'est propulsé par une stack hyper badass sous le capot 😉 .

Étendre notre app avec un appel asynchrone

Nous allons maintenant ajouter un second bouton à notre app. Il déclenchera un appel AJAX pour récupérer un message depuis le serveur. Pour le bien de la démo, cet appel enverra aussi quelques données, le nombre codé en dur 1234.

L'extrémité du serveur (endpoint 🇬🇧)

  • Créez un fichier src/shared/routes.js contenant :
// @flow

// eslint-disable-next-line import/prefer-default-export
export const helloEndpointRoute = (num: ?number) => `/ajax/hello/${num || ':num'}`

Cette fonction est une petite aide pour nous aider à produire les lignes suivantes :

helloEndpointRoute()     // -> '/ajax/hello/:num' (for Express)
helloEndpointRoute(1234) // -> '/ajax/hello/1234' (for the actual call)

Maintenant, créons rapidement un fichier de test pour nous assurer que tout fonctionne correctement :

  • Créez un fichier src/shared/routes.test.js contenant :
import { helloEndpointRoute } from './routes'

test('helloEndpointRoute', () => {
  expect(helloEndpointRoute()).toBe('/ajax/hello/:num')
  expect(helloEndpointRoute(123)).toBe('/ajax/hello/123')
})
  • Lancez yarn test et tous les tests devraient se dérouler avec succès

  • Dans src/server/index.js, ajoutez les lignes suivantes :

import { helloEndpointRoute } from '../shared/routes'

// [Au-dessous de app.get('/')...]

app.get(helloEndpointRoute(), (req, res) => {
  res.json({ serverMessage: `Hello from the server! (received ${req.params.num})` })
})

Nouveaux containers

  • Créez un fichier src/client/container/hello-async-button.js contenant :
// @flow

import { connect } from 'react-redux'

import { sayHelloAsync } from '../action/hello'
import Button from '../component/button'

const mapStateToProps = () => ({
  label: 'Say hello asynchronously and send 1234',
})

const mapDispatchToProps = dispatch => ({
  handleClick: () => { dispatch(sayHelloAsync(1234)) },
})

export default connect(mapStateToProps, mapDispatchToProps)(Button)

Afin de démontrer comment vous passeriez un paramètre à votre appel asynchrone et pour garder les choses simples, nous allons coder en dur la valeur 1234 ici. Typiquement, cette valeur pourrait venir d'un champ de formulaire rempli par l'utilisateur.

  • Créez un fichier src/client/container/message-async.js contenant :
// @flow

import { connect } from 'react-redux'

import MessageAsync from '../component/message'

const mapStateToProps = state => ({
  message: state.hello.get('messageAsync'),
})

export default connect(mapStateToProps)(MessageAsync)

Vous pouvez voir que dans ce container, nous faisons référence à une propriété messageAsync, que nous allons bientôt ajouter à notre reducer.

Ce dont nous avons besoin maintenant, c'est de créer l'action sayHelloAsync.

Fetch

💡 Fetch est une fonction JavaScript standardisée pour faire des appels asynchrones inspirée par les méthodes AJAX de jQuery.

Nous allons utiliser fetch pour faire des appels au serveur depuis le client. fetch n'est pas encore supporté par tous les navigateurs, donc nous allons avoir besoin d'un polyfill (c'est-à-dire un ensemble de fonctions permettant de simuler une ou des fonctionnalités qui ne sont pas nativement disponibles dans le navigateur).

isomorphic-fetch est un polyfill qui fait fonctionner fetch de façon cross-browsers et dans Node aussi ! 👌

  • Lancez yarn add isomorphic-fetch

Puisque nous utilisons eslint-plugin-compat, nous allons avoir besoin d'indiquer que nous utilisons un polyfill pour fetch, histoire de ne pas recevoir des warning à ce sujet.

  • Ajoutez les lignes suivantes à votre fichier .eslintrc.json :
"settings": {
  "polyfills": ["fetch"]
},

3 actions asynchrones

sayHelloAsync ne va pas être une action normale. Les actions asynchrones sont le plus souvent séparées en 3 actions qui déclenchent 3 states différents: une action requête ou chargement (request ou loading 🇬🇧), une action succès (success 🇬🇧) et une action échec (failure 🇬🇧)

  • Éditez le fichier src/client/action/hello.js comme ceci :
// @flow

import 'isomorphic-fetch'

import { createAction } from 'redux-actions'
import { helloEndpointRoute } from '../../shared/routes'

export const SAY_HELLO = 'SAY_HELLO'
export const SAY_HELLO_ASYNC_REQUEST = 'SAY_HELLO_ASYNC_REQUEST'
export const SAY_HELLO_ASYNC_SUCCESS = 'SAY_HELLO_ASYNC_SUCCESS'
export const SAY_HELLO_ASYNC_FAILURE = 'SAY_HELLO_ASYNC_FAILURE'

export const sayHello = createAction(SAY_HELLO)
export const sayHelloAsyncRequest = createAction(SAY_HELLO_ASYNC_REQUEST)
export const sayHelloAsyncSuccess = createAction(SAY_HELLO_ASYNC_SUCCESS)
export const sayHelloAsyncFailure = createAction(SAY_HELLO_ASYNC_FAILURE)

export const sayHelloAsync = (num: number) => (dispatch: Function) => {
  dispatch(sayHelloAsyncRequest())
  return fetch(helloEndpointRoute(num), { method: 'GET' })
    .then((res) => {
      if (!res.ok) throw Error(res.statusText)
      return res.json()
    })
    .then((data) => {
      if (!data.serverMessage) throw Error('No message received')
      dispatch(sayHelloAsyncSuccess(data.serverMessage))
    })
    .catch(() => {
      dispatch(sayHelloAsyncFailure())
    })
}

Au lieu de retourner une action, sayHelloAsync retourne une fonction qui lance l'appel fetch. fetch retourne une Promise, que nous utilisons pour expédier différentes actions selon l'état actuel de notre appel asynchrone.

3 gestionnaires d'actions asynchrones

Gérons ces différentes actions dans src/client/reducer/hello.js:

// @flow

import Immutable from 'immutable'
import type { fromJS as Immut } from 'immutable'

import {
  SAY_HELLO,
  SAY_HELLO_ASYNC_REQUEST,
  SAY_HELLO_ASYNC_SUCCESS,
  SAY_HELLO_ASYNC_FAILURE,
} from '../action/hello'

const initialState = Immutable.fromJS({
  message: 'Initial reducer message',
  messageAsync: 'Initial reducer message for async call',
})

const helloReducer = (state: Immut = initialState, action: { type: string, payload: any }) => {
  switch (action.type) {
    case SAY_HELLO:
      return state.set('message', action.payload)
    case SAY_HELLO_ASYNC_REQUEST:
      return state.set('messageAsync', 'Loading...')
    case SAY_HELLO_ASYNC_SUCCESS:
      return state.set('messageAsync', action.payload)
    case SAY_HELLO_ASYNC_FAILURE:
      return state.set('messageAsync', 'No message received, please check your connection')
    default:
      return state
  }
}

export default helloReducer

Nous avons ajouté un nouveau champ dans notre store, et nous le modifions avec différents messages selon l'action que nous recevons. Pendant SAY_HELLO_ASYNC_REQUEST, nous montrons Loading.... SAY_HELLO_ASYNC_SUCCESS modifie messageAsync d'une façon similaire à comment SAY_HELLO modifie message. SAY_HELLO_ASYNC_FAILURE donne un message d'erreur.

Redux-thunk

Dans src/client/action/hello.js, nous avons créé sayHelloAsync, un créateur d'actions qui retourne une fonction. En fait, ce n'est pas une fonctionnalité qui est nativement supportée par Redux. Afin de pouvoir effectuer ces actions asynchrones nous avons besoin d'étendre la fonctionnalité de Redux avec le middleware redux-thunk.

  • Lancez yarn add redux-thunk

  • Modifier votre fichier src/client/index.jsx comme ceci :

// @flow

import 'babel-polyfill'

import React from 'react'
import ReactDOM from 'react-dom'
import { AppContainer } from 'react-hot-loader'
import { Provider } from 'react-redux'
import { createStore, combineReducers, applyMiddleware, compose } from 'redux'
import thunkMiddleware from 'redux-thunk'

import App from './app'
import helloReducer from './reducer/hello'
import { APP_CONTAINER_SELECTOR } from '../shared/config'
import { isProd } from '../shared/util'

// eslint-disable-next-line no-underscore-dangle
const composeEnhancers = (isProd ? null : window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__) || compose

const store = createStore(combineReducers({ hello: helloReducer }),
  composeEnhancers(applyMiddleware(thunkMiddleware)))

const rootEl = document.querySelector(APP_CONTAINER_SELECTOR)

const wrapApp = (AppComponent, reduxStore) =>
  <Provider store={reduxStore}>
    <AppContainer>
      <AppComponent />
    </AppContainer>
  </Provider>

ReactDOM.render(wrapApp(App, store), rootEl)

if (module.hot) {
  // flow-disable-next-line
  module.hot.accept('./app', () => {
    // eslint-disable-next-line global-require
    const NextApp = require('./app').default
    ReactDOM.render(wrapApp(NextApp, store), rootEl)
  })
}

Ici, nous passonsredux-thunk à la fonction applyMiddleware de Redux. Afin que les outils de développement Redux continuent de fonctionner, nous avons aussi besoin d'utiliser la fonction compose de Redux. Ne vous en faites pas trop à propos de cette partie, retenez juste qu'on améliore Redux avec redux-thunk.

  • Modifiez le fichier src/client/app.jsx comme ceci :
// @flow

import React from 'react'
import HelloButton from './container/hello-button'
import HelloAsyncButton from './container/hello-async-button'
import Message from './container/message'
import MessageAsync from './container/message-async'
import { APP_NAME } from '../shared/config'

const App = () =>
  <div>
    <h1>{APP_NAME}</h1>
    <Message />
    <HelloButton />
    <MessageAsync />
    <HelloAsyncButton />
  </div>

export default App

🏁 Lancez yarn start et yarn dev:wds et vous devriez maintenant être capable de cliquer sur le bouton "Say hello asynchronously and send 1234" et de récupérer le message correspondant depuis le serveur ! Puisque vous travaillez en local, l'appel est instantané, mais si vous ouvrez les outils de développement Redux, vous remarquerez que chaque clic déclenche à la fois SAY_HELLO_ASYNC_REQUEST et SAY_HELLO_ASYNC_SUCCESS, ce qui fait que le message passe par le state intermédiaire Loading... comme on l'attendait.

Vous pouvez vous félicitez, c'était une section intense 🎉 👏 ! Mais ajoutons quelques tests 😉

Tests

Dans cette section, nous allons tester nos actions et notre reducer. Commençons avec les actions.

Afin d'isoler la logique spécifique au fichier action/hello.js, on va se moquer des choses qui ne le concerne pas, ainsi que cette requête AJAX fetch qui ne devrait pas déclencher un véritable appel AJAX dans nos tests.

  • Lancez yarn add --dev redux-mock-store fetch-mock

  • Créez un fichier src/client/action/hello.test.js contenant :

import fetchMock from 'fetch-mock'
import configureMockStore from 'redux-mock-store'
import thunkMiddleware from 'redux-thunk'

import {
  sayHelloAsync,
  sayHelloAsyncRequest,
  sayHelloAsyncSuccess,
  sayHelloAsyncFailure,
} from './hello'

import { helloEndpointRoute } from '../../shared/routes'

const mockStore = configureMockStore([thunkMiddleware])

afterEach(() => {
  fetchMock.restore()
})

test('sayHelloAsync success', () => {
  fetchMock.get(helloEndpointRoute(666), { serverMessage: 'Async hello success' })
  const store = mockStore()
  return store.dispatch(sayHelloAsync(666))
    .then(() => {
      expect(store.getActions()).toEqual([
        sayHelloAsyncRequest(),
        sayHelloAsyncSuccess('Async hello success'),
      ])
    })
})

test('sayHelloAsync 404', () => {
  fetchMock.get(helloEndpointRoute(666), 404)
  const store = mockStore()
  return store.dispatch(sayHelloAsync(666))
    .then(() => {
      expect(store.getActions()).toEqual([
        sayHelloAsyncRequest(),
        sayHelloAsyncFailure(),
      ])
    })
})

test('sayHelloAsync data error', () => {
  fetchMock.get(helloEndpointRoute(666), {})
  const store = mockStore()
  return store.dispatch(sayHelloAsync(666))
    .then(() => {
      expect(store.getActions()).toEqual([
        sayHelloAsyncRequest(),
        sayHelloAsyncFailure(),
      ])
    })
})

Bien, jetons un oeil à ce qui se passe ici. D'abord, on mock (on ignore) le store Redux en utilisant const mockStore = configureMockStore([thunkMiddleware]). En faisant cela, on peut expédier des actions sans qu'ils déclenchent la logique du reducer. Pour chaque test, on mock fetch en utilisant fetchMock.get() et le faisons retourner ce que nous voulons. En fait, ce qu'on teste avec expect(), c'est quelles séries d'actions ont été expédiées par le store, grâce à la fonction store.getActions() de redux-mock-store. Après chaque test, on restaure le comportement normal de fetch avec fetchMock.restore().

Et maintenant, testons notre reducer (ce qui est beaucoup plus simple) :

  • Créez un fichier src/client/reducer/hello.test.js contenant :
import {
  sayHello,
  sayHelloAsyncRequest,
  sayHelloAsyncSuccess,
  sayHelloAsyncFailure,
} from '../action/hello'

import helloReducer from './hello'

let helloState

beforeEach(() => {
  helloState = helloReducer(undefined, {})
})

test('handle default', () => {
  expect(helloState.get('message')).toBe('Initial reducer message')
  expect(helloState.get('messageAsync')).toBe('Initial reducer message for async call')
})

test('handle SAY_HELLO', () => {
  helloState = helloReducer(helloState, sayHello('Test'))
  expect(helloState.get('message')).toBe('Test')
})

test('handle SAY_HELLO_ASYNC_REQUEST', () => {
  helloState = helloReducer(helloState, sayHelloAsyncRequest())
  expect(helloState.get('messageAsync')).toBe('Loading...')
})

test('handle SAY_HELLO_ASYNC_SUCCESS', () => {
  helloState = helloReducer(helloState, sayHelloAsyncSuccess('Test async'))
  expect(helloState.get('messageAsync')).toBe('Test async')
})

test('handle SAY_HELLO_ASYNC_FAILURE', () => {
  helloState = helloReducer(helloState, sayHelloAsyncFailure())
  expect(helloState.get('messageAsync')).toBe('No message received, please check your connection')
})

Avant chaque test, on initialise helloState avec le résultat par défaut de notre reducer (le cas default de notre switch dans le reducer, qui retourne initialState). Les tests deviennent alors très explicites, on s'assure juste que le reducer modifie message et messageAsync correctement selon l'action qu'il reçoit.

🏁 Lancez yarn test. Tout devrait être vert !

Prochaine: 06 - React Router, Server-Side Rendering, Helmet

Retourner à la section précédente ou au sommaire.