La plupart des frameworks Front-end modernes utilisent un découpage en composant pour morceler une application. Chaque composant dispose de son propre state et peut recevoir des attributs qui permettent de le réutiliser dans différentes situations. Mais parfois plusieurs composants doivent partager un state sans forcément avoir un ancêtre commun proche et ont besoin d'être informés de chaque changement.

Redux et Mobx sont 2 frameworks qui permettent justement de solutionner ce problème gràce à la mise en place d'un state partagé. Même si ces 2 frameworks répondent à la même problématique, l'approche est complètement différente.

MobX

MobX utilise le principe de Programmation Reactive afin d'observer les changements d'état.

Flow mobx

  • Les Actions permettent de modifier le state.
  • Le State défini les propriétés à sauvegarder.
  • Computed permet de définir des propriétés dérivées du state.
  • Les Reactions définissent le comportement à adopter lors du changement d'état du state.

Pour mettre en place ces différents blocs on peut utiliser le système de décorateur.

import mobx, {observable, action, computed} from 'mobx'

// Empêche les modifications en dehors du state
mobx.useStrict(true)

class Tchat {

  @observable messages = []
  @observable notifications = 0;

  @action addMessage (message) {
    this.messages.push(message)
    this.notifications++
  }

  @computed get firstMessage () {
    return this.messages.length > 0 ? this.messages[0] : null
  }

}

let tchat = new Tchat()
tchat.observe({ messages } => {
  // Ici le traitement à faire quand les messages changent
})
tchat.addMessage('Hello') 
tchat.messages.push('Hello') // INTERDIT en mode strict

Une fois les propriétés désignées comme observables MobX se chargera de détecter les changements et on pourra s'abonner à ces changements à l'aide de la méthode observer.

Inconvénients

MobX observe les changements de manière implicite gràce au système d'observable. Ce système fonctionne la plupart du temps mais il faut bien comprendre les implications pour éviter certaines surprises.
Par exemple, il ne peut pas tracker les changements sur des propriétés qui n'existent pas au moment de l'initialisation de l'observer :

autorun(() => {
    // Ne fonctionne pas car la référence ne change pas
    console.log(message.likes)
    // Ne fonctionne pas car likes[0] n'existe pas lors de l'éxécution du code
    console.log(message.likes[0])
})

autorun(() => {
  // Fonctionne
  console.log(message.likes.join(', '))
  // Fonctionne
  console.log(messages.length)
})

message.likes.push("Jennifer")

Pour plus de détails n'hésitez pas à lire la documentation.

Avantages

MobX vous permet de construire votre état comme une classe standard et peut s'adapter à n'importe quelle structure. Il suffit de mettre en place quelques décorateurs pour que le système fonctionne.

Redux

Redux utilise une approche plus fonctionnelle avec un système de reducers.

const messages = (state = [], action) => {
  switch (action.type) {
    case 'ADD_MESSAGE':
      return [...state, action.message]
    default:
      return state
  }
}

let state = messages()
state = messages(state, {type: 'ADD_MESSAGE', message: 'Premier message'})
state = messages(state, {type: 'ADD_MESSAGE', message: 'Second message'})
console.log('messages : ', state)

Le reducer prend en paramètre le state précédent ainsi que l'action à effectuer et renvoie un nouvel état en fonction. Cela permet d'introduire 2 concepts essentiels :

  • Les fonctions doivent être pure et ne dépendre d'aucun paramètre extérieur et ne doivent rien muter.
  • Le state est immutable, chaque action renvoie le même state ou un nouveau state sans altérer le précédent. Si on veut ajouter un élément à un tableau, on créera un nouveau tableau contenant le nouvel élément.

Ce reducer permet de construire un store redux :

import {createStore, combineReducers} from 'redux'

// On crée notre store (en lui passant notre reducer)
const store = createStore(messages)

// On peut "muter" le store
store.dispatch({type: 'ADD_MESSAGE', message: 'Premier message'})

// On peut récupérer l'état depuis ce store
console.log('messages : ', store.getState())

// On peut observer les changement du state
store.subscribe(() => {
  console.log('Le state a changé !', store.getState())
})

C'est quasiment tout ce qu'il y a à connaitre à propos du fonctionnement de base de Redux. Autour de ce système il sera possible de construire des actions afin d'émettre plus facilement les actions :

export const addMessage = (message) => {
  return {
    type: 'ADD_MESSAGE',
    message
  }
}

export const increment = () => {
  return {
    type: 'INCREMENT'
  }
}

Il est aussi possible de créer un store en combinant plusieurs reducers ensemble :

import {createStore, combineReducers} from 'redux'
import { addMessage, increment } from './actions'

// Reducer
const messages = (state = [], action) => {
  switch (action.type) {
    case 'ADD_MESSAGE':
      return [...state, action.message]
    default:
      return state
  }
}

const counter = (state = 0, action) => {
  switch (action.type) {
    case 'INCREMENT':
      return state + 1
    case 'DECREMENT':
      return state - 1
    default:
      return state
  }
}

// On combine les reducer
const tchatApp = combineReducers({
  messages,
  counter
})

export const store = createStore(tchatApp)

// Exemple de communication avec le store
store.dispatch(addMessage('Salut les gens'})
window.setInterval(() => store.dispatch(increment()), 500)

Par rapport à MobX il y a moins de "magie" et beaucoup de chose devront être implémentées à la main.

Par exemple notre subscribe va être déclenché à chaque changement du state, ce qui est loin d'être idéal en terme de performances. Si on veut observer seulement des changements au niveau des messages, il faudra passer par une librairie tiers :

import { createSelector } from 'reselect'

store.subscribe(createSelector(
  [(state) => state.messages],
  (messages) => {
    console.log('rerendering messages')
  }
))

Avantages

Redux, gràce à son approche fonctionnel, permet d'avoir un code plus prévisible sans effet de bord. Chaque mutation entraine la génération d'un nouveau state ce qui facilite la mise en place de tests et le suivi des changements dans l'interface. On pourra garder en mémoire les anciens state et facilement revenir en arrière gràce à ça.

Inconvénients

En revanche, à cause de l'immutabilité, Redux va vous imposer de normaliser votre state afin d'avoir la structure la plus "plate" possible. De la même manière, le JavaScript n'est pas forcément un langage pensé pour l'immutabilité et il sera quasi nécessaire de passer par des librairies tiers comme Immutable pour se simplifier la tâche.

Intégration dans React

Enfin les 2 frameworks s'intègrent bien avec ReactJS :

MobX

On commence par installer mobx-react, puis il suffit d'importer observer

import {observer} from "mobx-react";

@observer 
class TchatView extends React.Component {
    render() {
        return <div>{this.props.messages.join(', ')}</div>
    }
}

Il sera en mesure de détecter la propriétés observable utilisées et rerendra la vue à chaque changement.

Redux

Pour redux on peut utiliser react-redux et on commencera par injecter le store dans notre application.

import React, { Component } from 'react'
import { store } from './store/index'
import { Provider } from 'react-redux'

class App extends Component {
  render() {
    return (
      <Provider store={store}>
        <div>
          ...
        </div>
      </Provider>
    );
  }
}

export default App;

On pourra ensuite utiliser les actions et le state dans nos composants

import React from 'react'
import ReactDOM from 'react-dom'
import { connect } from 'react-redux'
import { addMessage } from '../store/actions'

class Tchat extends React.Component {

  constructor(props) {
    super(props)
    this.state = {
      message: ''
    }
  }

  handleChange(event) {
    this.setState({message: event.target.value})
  }

  render() {

    return (
      <div className="row chat-window">
        <div className="panel panel-default">
          <div className="panel-body msg_container_base" ref={(container) => { this.container = container }}>
            {this.props.messages.map((message, k) => {
              return (
                <div key={k}>{message}</div>
              )
            })}
          </div>
          <input
            value={this.state.message}
            onChange={this.handleChange.bind(this)}
            id="btn-input" type="text"
            className="form-control input-sm chat_input"
            placeholder="Ecrivez votre message"/>
          <button
            className="btn btn-primary btn-sm"
            onClick={() => this.props.onSubmit(this.state.message)}>
              Envoyer
          </button>
        </div>
      </div>
    )
  }
}

const mapStateToProps = (state) => {
  return {
    messages: state.messages
  }
}

const mapDispatchToProps = (dispatch) => {
  return {
    onSubmit: (message) => {
      dispatch(addMessage(message))
    }
  }
}

export default connect(mapStateToProps, mapDispatchToProps)(Tchat)

Il y a un peu plus de boilerplate car les choses se font de manière plus explicite :

  • mapStateToProps, permet de définir les propriétés à envoyer au composant depuis le state
  • mapDispatchToProps, permet de créer des méthodes qui seront ajouter au composant et qui permettront de déclencher des mutations.