Baywatch : d’Angular à React, avec une dose de ReactNative

Baywatch : d'Angular à React, avec une dose de ReactNative

Je n’ai pas pu résister longtemps à l’envie de réécrire l’application Baywatch en React, notre application (et la vôtre) de partage de veille. La première version Angular, développée il y a 3 ans, était un petit sac de nœuds mélangeant plusieurs paradigmes d’Angular: événement, factory, contrôleur, directives…

Quelques semaines plus tard (~5), la refonte est terminée. Baywatch, tout beau tout neuf, tourne sur la triptyque huppée du moment : Webpack, React & Redux 💕.

TL;DR

La PR

Pourquoi une refonte ?

Cette refonte, purement technique, concerne uniquement l’application Web (et non l’API), sans ajouter, ni supprimer de fonctionnalités et tout en gardant le même design/markup. Le but était donc de remplacer Angular par React pour moderniser la stack et avoir une base de code attractive et maintenable.

Je ne parle pas ici de migration car je n’ai réutilisé aucun code. Mais il est tout à fait possible de faire co-exister du code Angular et React, pour entreprendre une migration progressive. Celle-ci peut commencer par les feuilles de l’arbre applicatif puis remonter vers le tronc (le routeur en général), l’article « Our journey migrating 100k lines of code from AngularJS to React (Chapter 1) » détaille ce point-ci.

Baywatch fonctionnait bien, mais l’ajout de nouvelles fonctionnalités était compliqué avec Angular :

  • Base de code difficile et complexe ;
  • Manque de motivation relatif à Angular ;
  • Frustration.

La courbe d’apprentissage d’Angular est assez élevée, notamment à cause de ses nombreux concepts : directives, services, factory, digest, filter et j’en passe. Angular est un framework (contrairement à React qui est une librairie) et implique une compréhension de tous ces concepts.

Voici ma “rage curve” pleine de mauvaise foi pour illustrer mon ressenti 🙃 :

Rage curve React/Angular

J’ai découvert entre temps React et Redux, enterrant toute motivation de maintenir du code Angular. La courbe d’apprentissage de React est plus légère. On assimile vite les quelques concepts inhérents à React : composants, state, props & JSX. Son API publique est simple et abstrait la complexité interne.

Ce qui peut s’avérer plus compliqué est l’application des bonnes pratiques et concepts d’architecture avancée (High order component, Redux, containers… ). Mais cet apprentissage se fait progressivement, contrairement à un Angular plus monolithique. Pour ceux qui voudraient commencer sur React, je conseille l’excellent create-react-app permettant d’abstraire webpack/babel et se focaliser uniquement sur le code React.

On dépoussière la stack: Yarn, Webpack & Babel

Bye

Yarn
https://yarnpkg.com/

L’ensemble des dépendances est géré par Yarn (et donc npm en coulisses). En plus du gain de vitesse lors de la résolution des dépendances, Yarn verrouille automatiquement les dépendances (byebye npm shrinkwrap). Plus besoin également d’ajouter le --save afin d’ajouter la dépendance au package.json. À noter quelques difficultés avec des dépendances Github (référence de commit, non taguées, fork).

Webpack 2
https://webpack.js.org/

Webpack s’occupe du build de l’application. Sous ses airs compliqués, cet outil est assez simple à prendre en main. Pour Baywatch, nous avons deux bundles compilés : un pour le code userland et l’autre pour les vendors :

entry: {
 app: APP_MODULE,
 vendor: VENDOR_MODULES
},

Pensez à gérer dans votre config le build en mode production (UglifyJsPlugin, LoaderOptionPlugin…) et à utiliser la version prod de React, les performances n’en seront que meilleures et les petites connexions internet vous remercieront 🐢.

new webpack.DefinePlugin({
      'process.env.NODE_ENV': JSON.stringify('production'),
    }),

La migration vers la version 2 de Webpack n’est pas trop compliqué, la nouvelle documentation est assez claire sur ce sujet. Je n’ai pas eu de gros gain au niveau de la taille des mes bundles malgré la promesse du tree-shaking 😢.

Babel
https://babeljs.io/

Babel permet de débloquer ✌ la syntaxe ES5/6 ✌ :

Cet ensemble de nouvelles syntaxes permet de livrer un code moins verbeux. Attention à ne pas trop en abuser, cela peux vite déboucher sur un code trop méta (« ES6 is great, but use it cautiously »).

👉 Note sur Babel et Webpack 2

La version 1 de Webpack n’était pas capable de parser des modules ES6, c’est pourquoi Babel les convertit en module CommonJS. En effet lorsque vous renseignez dans votre .babelrc le preset es2015:

{
  "presets": [
    "es2015"
  ]
}

Celui-ci a pour défaut commonjs comme type de module (cf. la doc de Babel). Bonne nouvelle, Webpack 2 est maintenant capable de parser des modules ES6, il n’est donc plus utile de les convertir en CommonJS. Pour désactiver la conversion, il suffit de passer le flag modules à false :

{
  "presets": [
    ["es2015", { "modules": false }]
  ]
}

En plus du gain de vitesse lors du build, Webpack peut désormais détecter les blocs de code morts et les retirer de vos bundles (cf. tree-shaking).

React & co

Les raisons concernant le choix de React ont été évoquées plus haut, je vais détailler ici les librairies utilisées conjointement avec React. La première, indispensable, est la fameuse librairie Redux.

Redux

React est une librairie et non un framework. Sans autre outil que React, il est difficile de rendre une application maintenable et scalable. Dès que le nombre de composants augmente, on tombe vite dans les méandres des callbacks passés via les props, ou on commence à utiliser le context à tout va. Il en est de même pour les données applicatives, souvent éclatées au travers des composants.

Pour répondre à ce problème, Facebook a créé une spécification et son implémentation : Flux. J’utilise une des implémentations les plus connues : Redux. Il en existe d’autres, mais Redux s’est imposée (à juste titre) comme la référence. Cette librairie est pour moi indispensable dès qu’une application contient plus de 3–4 composants. Une fois maîtrisée (ce qui passe par beaucoup de veille technique), elle désamorce en grande partie la complexité d’une application : le workflow est clair, rodé, et satisfait 95% des cas d’utilisation.

Redux permet de mettre en place très facilement des patterns favorisant l’expérience utilisateur

Exploiter le LocalStorage

Redux apporte le concept du ✨ single source of truth ✨, vos données sont stockées dans un seul et même objet javascript. Baywatch utilise la librairie redux-persist permettant de synchroniser facilement une partie de votre store avec votre LocalStorage et ainsi d’améliorer la performance et l’expérience utilisateur.

const store = createStore(
  reducer,
  undefined,
  compose(
    applyMiddleware(...),
    autoRehydrate()
  )
)

// Begin periodically persisting the store
persistStore(store)

LocalStorage

Undo/redo

Mettre en place un système de undo/redo est chose aisée grâce à l’architecture propre de Redux. Toutes les interactions passent par les reducers, et on peut ainsi garder facilement un historique de l’état du store pour naviguer dans le temps. La librairie redux-undo permet d’implémenter facilement ce pattern.

Optimistic UI

Grâce à Redux, on peut facilement mettre en place le pattern optimistic UI. Dans 95% des cas, notre API va retourner les données attendues. On peut donc mettre à jour notre UI pour signaler que l’opération s’est bien déroulée sans même attendre la réponse. Baywatch utilise ce pattern pour augmenter sensiblement la vitesse perçue par l’utilisateur.

Une action asynchrone permettant d’ajouter en favoris un bookmark se sépare en 3 états :

⏳ ADD_FAVORITE_PENDING
✅ ADD_FAVORITE_SUCCESS
🚫 ADD_FAVORITE_ERROR

On peut dès l’action ADD_FAVORITE_PENDING mettre à jour notre interface pour signaler l’ajout en favoris (passer l’étoile en jaune ⭐️ par exemple). Deux cas se présentent ensuite :

  • ADD_FAVORITE_SUCCESS: cas attendu, tout s’est bien passé on ne fait rien ✅ ;
  • ADD_FAVORITE_ERROR: pas de chance, on repasse l’étoile dans son état initial ☹️.

Communication avec son API

La complexité réside souvent dans le couplage avec l’API : comment récupérer les données, à quel niveau ? La bonne pratique est de créer un middleware Redux pour gérer les appels. Chaque appel asynchrone se divise en 3 actions élémentaires :

⏳ LOAD_BOOKMARKS_PENDING
✅ LOAD_BOOKMARKS_SUCCESS
🚫 LOAD_BOOKMARKS_ERROR

L’action PENDING envoie la requête vers le serveur puis selon la résolution de la promesse, les actions correspondantes sont lancées (SUCCESS ou ERROR).

La librairie redux-axios-middleware permet de faciliter la mise en place d’un tel mécanisme. J’utilise souvent le client HTTP Axios car il propose des fonctionnalités intéressantes telles que les intercepteurs ou l’annulation de requête (contrairement à fetch).

const client = axios.create({
  baseURL:'http://localhost:8080/api',
  responseType: 'json'
});

let store = createStore(
  reducers, //custom reducers
  applyMiddleware(
    ...
    axiosMiddleware(client),
    ...
  )
)

export function loadBookmarks() {
  return {
    types: [LOAD_BOOKMARK_PENDING, LOAD_BOOKMARK_SUCCESS, LOAD_BOOKMARK_FAIL],
    normalize: true,
    payload: {
      request:{
        url:'/bookmarks'
      }
    }
  }
}

Les intercepteurs permettent d’injecter la logique due à l’authentification (token JWT, erreurs 401, préfixe de l’API…) :

interceptors: {
    request: [({getState}, config) => {
       let { auth } = getState()
       if (auth.token) {
         config.params.access_token = auth.token
       }

       return config
     }],
   },
   onError: ({ error, next, action }, options) => {
     const {status} = error.response
     if (status === 401) {
       next(push('/login'))
       next(logoutUser())
     }

     return defaultOnError({action, next, error}, options)
   }

Attention tout de même : la dernière version comporte un bug lié aux intercepteurs, qui a été corrigé dans la PR de mon collègue Thibault.

Normalisation des données

Lors de la construction d’une application, l’erreur fréquente est de stocker les données d’API en l’état dans ses stores Redux. Or, leur schéma n’est généralement pas optimal pour l’utiliser dans React. Il est important de passer par une étape de normalisation afin d’avoir un state optimal et facilement exploitable. Si votre API retourne une liste de ressources telle que :

{
  "bookmarks": [
      {"id": 122, url: "http://link1.io"},
      {"id": 123, url: "http://link2.io"},
    ]
}

La recherche sera coûteuse. La bonne pratique est d’indiquer les identifiants des ressources en clé :

{
    "bookmarks": {
        "122": {"id": 122, url: "http://link1.io"},
        "123": {"id": 123, url: "http://link2.io"},
    }
}

Ainsi, vous pouvez accéder directement à l’élément sans le rechercher (complexité constante O(1) contre complexité linéaire O(n)).

✌🔥✨🔥✨🔥✨ much math ✨✨✨ many skill such WOW ✨🔥✨🔥✨🔥✌

Il est également important de ne pas dupliquer vos données dans votre state (c’est-à-dire stocker des identifiants plutôt que des entités dupliquées au travers des stores) et éviter les structures imbriquées :

// 👎
{
  "boookmarks": {
    "id": 123,
    "url": "https://joli.beer/",
    "author": {
      "uid": 37,
      "username": "shinework"
    }
  }
}

// 👍
{
  "boookmarks": {
    123: {
      "url": "https://joli.beer/",
      "authorUid": 37,
    }
  },
  "authors": {
    37: {
      "username": "shinework"
    }
  },
}

Pour gérer ces problématiques, j’ai utilisé la librairie Normalizr permettant de définir des schémas de normalisation. Je n’ai eu qu’à les utiliser dans l’intercepteur de mon client HTTP pour appliquer les transformations sur la réponse de mon API :

interceptors: {
   response: [({getAction}, response) => {
     const action = getAction()
     return (action.normalize && response.data) ? normalize(response.data, ApiSchema) : response
   }],

L’excellent article « Avoiding Accidental Complexity When Structuring Your App State » détaille cette problématique en quelques préceptes :

  • Éviter de modéliser votre state selon ce que retourne votre API ;
  • Éviter de modéliser votre state selon ce que qu’affichent vos composants ;
  • Préférer les maps aux tableaux ;
  • Ne pas dupliquer vos données dans votre state ;
  • Ne pas dériver des données (utiliser des sélecteurs) ;
  • Normaliser vos données.

Architecture

Architecture globale

├── src/
│   ├── actions/
│   ├── api/
│   ├── assets/
│   ├── components/
│   ├── constants/
│   ├── containers/
│   ├── models/
│   ├── reducers/
│   ├── store/
│   ├── translations/
│   └── utils/
│   ├── config.js
│   ├── routes.js
│   ├── shortcuts.js
│   ├── app.js
│.babelrc
│package.json
│webpack.config.js
│yarn.lock

C’est une architecture récurrente d’une application Redux. L’ensemble de la stack Redux se trouve dans les répertoires actions, reducers, store, constants.

Le répertoire api contient mes schémas propres à normalizr et mon instance du client http axios.

A noter qu’il existe une autre architecture orientée fonctionnalités (je ne l’ai personnellement pas encore testé). Celle-ci a pour avantage de regrouper les fichiers par fonctionnalités (composants, reducers, actions…) rendant la navigation plus aisée.

├── src/
│   ├── bookmarks/
│            ├── bookmarkReducer.js
│            ├── bookmarkActions.js
│            ├── bookmarksContainer.js

Je pense que cela à du sens, l’article “How to better organize your React applications” détaille cette architecture.

Dépendances de Baywatch (hors développement)

{
 "axios": "^0.15.2",
 "clipboard": "^1.5.16",
 "history": "^3.0.0",
 "immutable": "^3.8.1",
 "jwt-decode": "^2.1.0",
 "lodash": "^4.17.2",
 "moment": "^2.17.0",
 "normalizr": "^2.2.1",
 "notie": "^3.9.5",
 "react": "^15.4.2",
 "react-dom": "^15.4.2",
 "react-dropzone": "^3.8.0",
 "react-infinite-scroller": "^1.0.4",
 "react-redux": "^4.4.6",
 "react-router": "^3.0.0",
 "react-router-redux": "^4.0.7",
 "react-select": "^1.0.0-rc.2",
 "react-shortcuts": "^1.3.1",
 "react-translate": "^5.0.0",
 "redux": "^3.1.2",
 "redux-auth-wrapper": "^0.9.0",
 "redux-axios-middleware": "^0.3.0",
 "redux-persist": "^4.0.1",
 "redux-persist-transform-immutable": "^4.1.0",
 "redux-thunk": "^1.0.3"
}

Routing / firewall

Sans surprise, j’ai utilisé react-router pour gérer le routing. Mes routes n’exposent que des composants “containers” (cf. ci-dessous). Afin de gérer facilement le firewall au niveau de mes routes, j’ai utilisé la librairie redux-auth-wrapper. Celle-ci permet de définir des prédicats sous forme de HOC autorisant ou non l’accès aux routes :

const UserIsAuthenticated = UserAuthWrapper({
  authSelector: state => state.auth,
  predicate: auth => auth.token,
  wrapperDisplayName: 'UserIsAuthenticated',
  failureRedirectPath: '/login'
})

// Dans le routeur
<Route component={UserIsAuthenticated(AppContainer)}>

Elle permet aussi de gérer facilement le mécanisme de redirection sur la route initiale après la connexion:

https://team.baywatch.io/login?redirect=/all

Composants containers / présentations

L’architecture smart/dumb components permet de séparer la logique des composants en deux types:

Composants container constitués de la logique métier (ce que fait l’application 🔧):

  • Appel des actions / handler ;
  • Récupération des données ;
  • Connexion aux stores Redux ;
  • Composant « factory » ou « wrapper », pour factoriser des comportements.

Composants de présentation (ce qu’affiche l’application 🖼):

  • Composants applicatifs (“widgets”) ;
  • Composants “purs” ;
  • UI / Markup / CSS.

Ceci dans le but de séparer au mieux les concepts et de rendre les composants de présentation réutilisables avec des données variées.

En pratique avec Redux, les composants de types container se composent généralement d’un connect avec la définition des mapStateToProps et mapDispatchToProps:

import {connect} from 'react-redux'
import GroupHeader from './../components/GroupHeader'
import {joinGroup, leaveGroup, askGroupInvitation} from './../actions/groups'

const mapStateToProps = (state, props) => {
  return {
    group: props.group,
    isPending: state.groups.isPending,
  }
}

const mapDispatchToProps = {
  leaveGroup,
  joinGroup,
  askGroupInvitation,
}

export default connect(mapStateToProps, mapDispatchToProps)(GroupHeader)

Les fonctions passées à connect via l’objet mapDispatchToProps vont être automatiquement appelées avec la méthode dispatch (ex. dispatch(leaveGroup())). Ainsi les composants dumb pourront appeler ces fonctions depuis leurs props sans se soucier de la logique de Redux.

La fonction mapStateToProps va quant à elle lier le state avec mon composant. À chaque modification du state, mes composants vont donc être re-rendus.

À noter que la méthode connect va automatiquement vérifier si les propriétés passées en props ont changé et effectuer un rendu ou non :

[pure] (Boolean): If true, connect() will avoid re-renders and calls to mapStateToProps, mapDispatchToProps, and mergeProps if the relevant state/props objects remain equal based on their respective equality checks. Assumes that the wrapped component is a “pure” component and does not rely on any input or state other than its props and the selected Redux store’s state. Default value: true

Et le mobile ?

Nous avons commencé un POC d’une application mobile pour Baywatch avec React Native, le but étant de valider la possibilité de réutiliser la stack Redux utilisée dans la webapp, à savoir :

  • Les actions ;
  • Les reducers.

Et ce fut un succès : nous avons pu développer uniquement les composants propres à React Native et développer très rapidement les fonctionnalités de base ✌.

Partage de la stack Redux entre web & mobile

La principale difficulté était donc d’exposer les répertoires actions, reducers, constants à notre projet React Native.

Première hypothèse : mettre l’application mobile et Web dans le même répertoire afin qu’elles partagent le même package.json. Mais cette méthode semble difficilement maintenable : on souhaite pouvoir changer indépendamment la version de React (ou autres librairies) sur les deux applications, chose impossible avec un package.json commun.

├── apps/
│   ├── mobile/
│   ├── web/
│   ├── node_modules/
│   ├── package.json

Deuxième hypothèse : avoir deux package.json pour chaque projet, ce qui semble le plus propre :

├── webapp/
│   ├── src/
│   ├── node_modules/
│   ├── package.json
├── mobileapp/
│   ├── src/
│   ├── node_modules/
│   ├── index.ios.js
│   ├── index.android.js
│   ├── package.json

Cette architecture semble correcte mais apporte un nouveau problème : on ne peut pas importer de fichiers d’un répertoire plus bas dans l’arborescence que le fichier package.json. Ainsi, impossible depuis le code de l’application mobile d’appeler notre stack Redux présente dans le répertoire webapp :

import {loadBookmarks} from './../../webapp/src/'

La solution idéale serait donc de créer un package à part, dédié à la stack Redux, qu’on ajoute aux dépendances de nos deux applications :

import {loadBookmarks} from 'baywatch-core'

Mais cela rend notre développement un peu plus laborieux. Pour l’heure, nous avons donc fait le choix d’une solution intermédiaire, en copiant simplement le répertoire src de notre application web dans le répertoire node_modules de notre application mobile. React Native ne supportant pas encore les liens symboliques, nous avons utilisé un outil publié par Wix afin de palier à ce problème : https://github.com/wix/wml.

wml add ../webapp/src/ ./node_modules/baywatch
wml start

Ce qui nous permet bien d’importer nos actions :

import {loadBookmarks} from 'baywatch/actions/bookmarks'

On attend donc avec impatience le support des liens symboliques avec React Native pour éviter ce hack disgracieux 🤓.

Conclusion

Il y a encore beaucoup de sujets à aborder sur cette refonte et le développement d’application React :

  • L’utilisation de immutable.js avec Redux ;
  • L’utilisation de sélecteurs et la mémoïsation ;
  • Les leviers de performances avec l’optimisation des rendus ;
  • L’intégration avec une application Symfony ;
  • Les tests avec Jest, Enzyme, Ava….

De manière générale, cette refonte a clarifié la base de code en supprimant quelques 8 000 lignes de codes. Il est aujourd’hui plus facile de maintenir l’application grâce à la scalabilité apportée par React & Redux. En désamorçant la complexité, ces librairies permettent de mieux se concentrer sur le détail applicatif permettant de délivrer des applications plus raffinées.

N’hésitez pas à nous contacter pour échanger sur des détails d’architecture ou d’implémentation. Vous nous trouverez aussi aux meetups React 🍻. Enfin, si vous êtes soucieux de votre veille et voulez la partager avec le reste de vos collègues, n’hésitez pas à tester Baywatch. Tout feedback est le bienvenu.

❤️🏄

blog comments powered by Disqus