---
marp: true
---
# React + Redux
## William Petit - S.C.O.P. Cadoles
---
## Amorçage d'un projet
---
## Méthode simplifiée: `create-react-app`
```bash
npm install -g create-react-app # Installation du générateur d'application
create-react-app my-app # Génération de l'application
cd my-app # Se placer dans le répertoire
npm start # Lancer le serveur de développement
```
https://github.com/facebook/create-react-app
---
## Méthode manuelle
### Installation des dépendances
```bash
mkdir my-app # Création du répertoire de l'application
cd my-app # Se placer dans le répertoire
npm init # Initialiser le projet NPM
# Installation des dépendances NPM
npm install --save \
react react-dom react-hot-loader \
webpack webpack-dev-server \
webpack-cli \
babel-core babel-loader \
babel-preset-env babel-preset-react \
babel-preset-stage-2 \
style-loader file-loader css-loader \
html-webpack-plugin clean-webpack-plugin \
uglifyjs-webpack-plugin webpack-merge
```
---
### Configuration de Webpack
```js
// my-app/webpack.common.js
const webpack = require('webpack')
const HTMLPlugin = require('html-webpack-plugin')
module.exports = {
entry: {
main: './src/index.js'
},
module: {
rules: [
{ test: /\.(js|jsx)$/, exclude: /node_modules/, use: ['babel-loader'] },
{ test: /\.(scss|sass)$/, use: ['style-loader', 'css-loader'] },
{ test: /\.(png|jpg|jpeg|gif|svg|woff|woff2|ttf|eot)(\?.*$|$)/, use: ['file-loader'] }
]
},
resolve: {
extensions: ['*', '.js', '.jsx']
},
plugins: [
new webpack.ProvidePlugin({
'React': 'react'
}),
new HTMLPlugin({
title: 'MyApp',
hash: true,
template: 'src/index.html'
})
]
}
```
---
```js
// my-app/webpack.dev.js
const webpack = require('webpack')
const common = require('./webpack.common.js')
const merge = require('webpack-merge')
const path = require('path')
module.exports = merge(common, {
devtool: 'inline-source-map',
mode: 'development',
plugins: [
new webpack.HotModuleReplacementPlugin()
],
output: {
path: path.join(__dirname, 'public'),
publicPath: '/',
filename: '[name].[hash].js'
},
devServer: {
contentBase: './public',
hot: true
}
})
```
---
```
// my-app/webpack.prod.js
const common = require('./webpack.common.js')
const merge = require('webpack-merge')
const path = require('path')
const webpack = require('webpack')
const CleanWebpackPlugin = require('clean-webpack-plugin')
const UglifyJSPlugin = require('uglifyjs-webpack-plugin')
module.exports = merge(common, {
mode: 'production',
output: {
path: path.join(__dirname, 'dist'),
publicPath: '/',
filename: '[name].[chunkhash].js'
},
devtool: 'source-map',
plugins: [
new CleanWebpackPlugin(['dist']),
new webpack.DefinePlugin({
'process.env.NODE_ENV': JSON.stringify('production')
}),
new UglifyJSPlugin({
sourceMap: true
})
]
})
```
---
### Compléter le fichier `package.json`
```js
// my-app/package.json
{
"scripts": {
"start": "webpack-dev-server --progress --colors --config webpack.dev.js",
"build": "webpack --progress --colors --config webpack.prod.js"
},
// ...
"babel": {
"presets": [
"env",
"react",
"stage-2"
],
"plugins": ["react-hot-loader/babel"]
}
}
```
---
### Création des fichiers source de base
```html
MyApp
<% for (var css in htmlWebpackPlugin.files.css) { %>
<% } %>
```
https://github.com/jantimon/html-webpack-plugin
---
```js
// my-app/src/index.js
import ReactDOM from 'react-dom'
import App from './app'
ReactDOM.render(, document.getElementById('app'))
```
```js
// my-app/src/app.js
import { Component } from 'react'
import { hot } from 'react-hot-loader'
class App extends Component {
render () {
return
Hello World !
}
}
export default hot(module)(App)
```
---
### Lancer le serveur de développement
```bash
npm start # Puis ouvrir http://localhost:8080 dans votre navigateur
```
### Générer une version pour la production
```bash
npm run build # Les fichiers générés seront dans le répertoire my-app/dist
```
---
## Les composants
---
## Différentes notations
### Classe ES6
```jsx
// my-app/src/components/hello-world.js
import React from 'react'
class HelloWorld extends React.Component {
render() {
return (
)
}
export default HelloWorld
```
---
## La syntaxe JSX
C'est une représentation proche du XML d'une arborescence de composants. Il ne faut pas faire d'amalgame, **ce n'est pas du HTML**.
La définition des attributs propres au DOM se fait via le nom de la propriété Javascript, pas de l'attribut HTML. **Par exemple l'attribut`class` en HTML devient `className` en JSX**.
Il en est de même pour la **définition des styles**.
---
## `props`
Elles permettent de passer des données aux composants.
```jsx
// my-app/src/components/props-example.js
import React from 'react'
class PropsExample extends React.Component {
render() {
return (
{ this.props.text }
)
}
}
export default PropsExample
// Exemple d'utilisation
//
```
---
### `props.children`
La propriété `children` permet de récupérer les composants 'enfants' qui pourraient être passés au composant lors de son rendu.
```jsx
// my-app/src/components/my-list.js
import React from 'react'
class MyList extends React.Component {
render() {
return (
{ this.props.children }
)
}
}
export default PropsExample
// Exemple d'utilisation
Item 1
Item 2
```
---
## `state`
La propriété `state` d'un composant représente l'état (au sens données) de celui ci.
```jsx
// my-app/src/components/clock.js
import React from 'react'
export default class Clock extends React.Component {
constructor(props) {
super(props)
// Initialisation du "state" du composant
this.state = {
time: new Date()
}
// On appelle la méthode tick() du composant
// toutes les secondes
setInterval(this.tick.bind(this), 1000);
}
// Méthode de rendu du composant
render() {
return (
Time: { this.state.time.toString() }
)
}
// La méthode tick() met à jour le state du composant avec
// la date courante
tick() {
this.setState({ time: new Date() });
}
}
```
---
## Événements du DOM
Les événements du DOM sont interceptés par le passage de "callbacks" sur les propriétés `on` des composants.
---
```jsx
// my-app/src/components/counter.js
import React from 'react'
export default class Counter extends React.Component {
constructor(props) {
super(props)
// Initialisation du "state" du composant
this.state = {
count: 0
}
// On "lie" les méthodes de la classe à l'instance
this.increment = this.increment.bind(this)
this.decrement = this.decrement.bind(this)
}
// Méthode de rendu du composant
render() {
return (
Count: { this.state.count }
)
}
// La méthode increment() incrémente la valeur du compteur de 1
increment() {
this.setState(prevState => ({ count: prevState.count+1 }))
}
// La méthode decrement() décrémente la valeur du compteur de 1
decrement() {
this.setState(prevState => ({ count: prevState.count-1 }))
}
}
```
---
## Gestion des styles
Différentes méthodologies existent pour gérer les styles des composants.
La méthode classique CSS (avec ou sans préprocesseur SCSS/SASS) est tout à fait viable.
Les styles des composants sont également souvent déclarés en Javascript et utilisés via les `props` des composants.
D'autres solutions plus avancées comme [styled-components](https://www.styled-components.com/) ou [CSS Modules](https://github.com/css-modules/css-modules) existent également. Elles mettent l'accent sur la modularité et la réutilisation des règles de style entre les composants.
---
### Déclaration des styles en Javascript
```jsx
// my-app/src/components/my-styled-component.styles.js
export default {
self: {
backgroundColor: 'red',
border: '1px solid black',
padding: '10px'
},
content: {
backgroundColor: 'blue',
border: '1px dotted white'
}
}
```
```jsx
// my-app/src/components/my-styled-component.js
import React from 'react'
import styles from './my-styled-component.styles.js'
export default class MyStyledComponent extends React.Component {
render() {
return (
Isnt it pretty ?
)
}
}
```
---
## Composition des styles en Javascript
En utilisant l'opérateur de décomposition `...`, il est possible
de composer les styles.
```jsx
// my-app/src/components/my-styled-component.js
import React from 'react'
import styles from './my-styled-component.styles.js'
export default class MyStyledComponent extends React.Component {
render() {
return (
Isnt it pretty ?
)
}
}
// Exemple d'utilisation
```
---
## Cycle de vie et méthodes
https://reactjs.org/docs/react-component.html
---
### Séquence de montage
1. `constructor(nextProps, currentState)`
2. `static getDerivedStateFromProps(nextProps, currentState)`
3. `render()`
4. `componentDidMount()`
---
### Séquence de mise à jour
1. `static getDerivedStateFromProps(nextProps, currentState)`
2. `shouldComponentUpdate(nextProps, nextState)`
3. `render()`
4. `getSnapshotBeforeUpdate(prevProps, prevState)`
5. `componentDidUpdate(prevProps, prevState, snapshot)`
---
### Séquence de démontage
1. `componentWillUnmount()`
---
### Gestion des erreurs
`componentDidCatch(error, info)`
Permet d'intercepter les erreurs qui pourraient être émises dans l'arbre des sous composants.
---
## Composants "contrôlés" (formulaires)
```jsx
// my-app/src/components/my-form.js
import React from 'react'
export default class MyForm extends React.Component {
constructor(props) {
super(props);
this.state = {name: ''};
this.handleChange = this.handleChange.bind(this);
this.handleSubmit = this.handleSubmit.bind(this);
}
handleChange(evt) {
this.setState({value: evt.target.value});
}
handleSubmit(evt) {
console.log(`Votre nom est ${this.state.name}`);
evt.preventDefault();
}
render() {
return (
);
}
}
```
---
## Intégrer React avec d'autres librairies
https://reactjs.org/docs/integrating-with-other-libraries.html
---
## Tester ses composants
https://facebook.github.io/jest/docs/en/tutorial-react.html
### Installer Jest
```bash
npm install --save-dev jest react-test-renderer babel-jest
```
### Configurer le script NPM
```js
// package.json
{
// ...
"scripts" : {
"test": "jest"
}
//...
}
```
---
## Créer un script de test
```
// my-app/src/components/my-styled-component.test.js
/* globals test, expect */
import MyStyledComponent from './my-styled-component'
import renderer from 'react-test-renderer'
test('Navbar snapshot', () => {
const component = renderer.create()
let tree = component.toJSON()
// Vérifier que le composant n'a pas changé depuis le dernier
// snapshot.
// Voir https://facebook.github.io/jest/docs/en/snapshot-testing.html
// pour plus d'informations
expect(tree).toMatchSnapshot()
// L'API expect() de Jest est disponible à l'adresse
// https://facebook.github.io/jest/docs/en/expect.html
// Il est possible d'effectuer des vérifications plus avancées
// grâce au projet Enzyme (vérification du DOM, etc)
// Voir http://airbnb.io/enzyme/ et
// https://facebook.github.io/jest/docs/en/tutorial-react.html#dom-testing
})
```
---
### Lancer les tests
```bash
npm test
```
---
## Gestion des routes
https://reacttraining.com/react-router/web/
### Installation du module
```bash
npm install --save react-router react-router-dom
```
---
### Déclaration basique des routes
```
// my-app/src/app.js
import React from 'react'
import { HashRouter } from 'react-router-dom' // ou BrowserRouter
import { Route, Switch, Redirect } from 'react-router'
import { Clock, MyStyledComponent, My404Page } from './components'
export default class App extends React.Component {
render() {
}
}
```
---
## Liens de navigation
```
// my-app/src/components/my-linked-component.js
import React from 'react'
import { Link } from 'react-router'
export default class App extends React.Component {
render() {
return (
)
}
}
```
---
## Paramètres de route
```
// my-app/src/app.js
import React from 'react'
import { HashRouter } from 'react-router-dom' // ou BrowserRouter
import { Route } from 'react-router'
import { MyRouteParameter } from './components'
export default class App extends React.Component {
render() {
}
}
```
```
// my-app/src/components/my-route-parameter.js
import React from 'react'
import { HashRouter } from 'react-router-dom' // ou BrowserRouter
import { Route } from 'react-router'
import { MyRouteParameter } from './components'
export default class App extends React.Component {
render() {
const { match } = this.props
Paramètre: { match.params.myParam }
}
}
```
---
### Sous routes
```
// my-app/src/app.js
import React from 'react'
import { HashRouter } from 'react-router-dom'
import { Route, Switch } from 'react-router'
import { ProductPage } from './components'
export default class App extends React.Component {
render() {
}
}
```
```
// my-app/src/component/product.js
import React from 'react'
import { Route, Switch } from 'react-router'
import ProductView from './product-view'
import ProductEdit from './product-edit'
export default class ProductPage extends React.Component {
render() {
const { match } = this.props
return (
)
}
}
```
---
## Et plus encore...
- [Transitions](https://reacttraining.com/react-router/web/example/animated-transitions)
- [Liens personnalisés](https://reacttraining.com/react-router/web/example/custom-link)
- [Chemins récursifs...](https://reacttraining.com/react-router/web/example/recursive-paths)
---
## État de l'application
---
## Architecture Flux
![center 100%](img/flux-simple-f8-diagram-with-client-action-1300w.png)
---
## Redux
https://redux.js.org/
---
![bg right:75% contain](./img/redux_flow.png?1)
## Flot des données
---
## `store`, `actions` et `reducers`
### Générateurs d'actions
```js
// actions.js
export const ADD_PRODUCT = 'ADD_PRODUCT'
export function addProduct (name, price) {
return {type: ADD_PRODUCT, product: {name, price}}
}
export const REMOVE_PRODUCT = 'REMOVE_PRODUCT'
export function removeProduct (name) {
return {type: REMOVE_PRODUCT, productName: name}
}
```
---
### "Reducer" racine
```js
// reducer.js
export default const rootReducer = (state, action) => {
console.log(`Action: ${JSON.stringify(action)}`)
switch (action.type) {
case ADD_PRODUCT:
// L'action est de type ADD_PRODUCT
// On ajoute le produit dans la liste et
// on retourne un nouvel état modifié
return {
products: [...state.products, action.product]
}
case REMOVE_PRODUCT:
// L'action est de type REMOVE_PRODUCT
// On filtre la liste des produits et on
// retourne un nouvel état modifié
return {
products: state.products.filter(p => p.name !== action.productName)
}
}
// Si l'action n'est pas gérée, on retourne l'état
// sans le modifier
return state
}
```
---
## Création du `store`
```javascript
// store.js
import { createStore } from 'redux'
import rootReducer from './reducer'
export function configureStore(initialState = { products: [] }) {
return createStore(
rootReducer,
initialState
)
}
```
---
## Tester
```js
// store.test.js
/* globals test, expect, jest */
import { addProduct, removeProduct } from './actions'
import { configureStore } from './store'
test('Ajout/suppression des produits', () => {
// On crée une instance de notre store
// avec le state par défaut
const store = configureStore()
// On crée un "faux" subscriber
// pour vérifier que l'état du store
// a bien été modifié le nombre de fois voulu
const subscriber = jest.fn()
// On attache notre faux subscriber
// au store
store.subscribe(subscriber)
// On "dispatch" nos actions
store.dispatch(addProduct('pomme', 5))
store.dispatch(addProduct('orange', 7))
store.dispatch(removeProduct('pomme'))
// On s'assure que notre subscriber a bien été
// appelé
expect(subscriber).toHaveBeenCalledTimes(3)
const state = store.getState()
// On s'assure que l'état du store correspond
// à ce qu'on attend
expect(state).toMatchObject({
products: [
{name: 'orange', price: 7}
]
})
})
```
---
## Actions asynchrones
https://github.com/gaearon/redux-thunk
```bash
npm install --save redux-thunk
```
---
### Ajout du middleware au `store`
```
// store.js
import { createStore, applyMiddleware } from 'redux'
import thunk from 'redux-thunk'
import rootReducer from './reducer'
export function configureStore(initialState = { products: [] }) {
return createStore(
rootReducer,
initialState,
applyMiddleware(thunk) // On ajoute le middleware 'thunk'
)
}
```
---
## Générateur d'actions asynchrones
```
// actions.js
export const SAVE_PRODUCT = 'SAVE_PRODUCT'
export const SAVE_PRODUCT_SUCCESS = 'SAVE_PRODUCT_SUCCESS'
export const SAVE_PRODUCT_FAILURE = 'SAVE_PRODUCT_FAILURE'
export function saveProduct (product) {
return (dispatch, getState) => {
dispatch({type: SAVE_PRODUCT, product})
return fetch('http://my-api/products', {
method: 'POST',
body: JSON.stringify(product)
})
.then(res => res.json())
.then(res => dispatch({ type: SAVE_PRODUCT_SUCCESS, product: res.data }))
.catch(error => dispatch({ type: SAVE_PRODUCT_FAILURE, error }))
}
}
```
---
## Utilisation avec React
### Installer les dépendances
```bash
npm install --save react-redux
```
---
### Déclaration de l'usage du `store` dans l'application
```
// my-app/src/index.js
import ReactDOM from 'react-dom'
import { Provider } from 'react-redux'
import App from './app'
import { configureStore } from './store'
const store = configureStore()
ReactDOM.render(
,
document.getElementById('app')
)
```
---
```
// my-app/src/app.js
import React from 'react'
import { addProduct } from './actions'
import { connect } from 'react-redux'
class App extends React.Component {
componentDidMount() {
// Toutes les 2 secondes, on "dispatch" une nouvelle
// action qui va ajouter un produit dans le store
setInterval(() => {
this.props.dispatch(addProduct(`My product #${Date.now()}`, Math.random()*10|0))
}, 2000)
}
render() {
// On crée une liste à partir des éléments présents
// dans le tableaux de produits
const items = this.props.products.map((p, i) => {
return
{p.name}
})
return (
{items}
)
}
}
// Cette fonction permet de "sélectionner" les éléments
// qui nous intéressent dans le state et de les exposer
// en tant que "props" sur notre composant
const mapStateToProps = state => {
return {
products: state.products
}
}
// On "emballe" notre composant dans un "higher order component" (ou H.O.C.)
// ce qui nous permet de récupérer les données à partir du store
export default connect(mapStateToProps)(App)
```
---
## Redux-Saga
ou comment gérer les **"effets de bord"**
---
![bg right:75% contain](./img/reduxsaga_flow.png?1)
## Flot des données
---
### Cas d'usage
React-Redux permet d'intégrer au sein du "flux de travail" Redux la gestion des opérations asynchrones.
**Exemple** _Appels à des API distantes, opérations liées au passage du temps, messages websockets..._
Notamment, il offre une gestion de la **synchronisation d'opérations asynchrones** i.e. je dois attendre que l'opération A et l'opération B soient terminées pour effectuer l'opération C.
---
### Installation de la dépendance
```bash
npm install --save redux-saga
```
---
### Ajout du middleware
```js
// src/store/store.js
import { createStore, applyMiddleware, combineReducers, compose } from 'redux'
import myReducer from '../reducers/my'
import rootSaga from '../sagas/root'
import createSagaMiddleware from 'redux-saga'
const sagaMiddleware = createSagaMiddleware()
const rootReducer = combineReducers({
my: myReducer,
});
export function configureStore(initialState = {}) {
const store = createStore(
rootReducer,
initialState,
compose(
applyMiddleware(sagaMiddleware)
)
)
sagaMiddleware.run(rootSaga);
return store;
}
```
---
## Création de la saga "racine"
_Avec une saga "factice" d'authentification_
```js
// src/sagas/root.js
import { all, takeLatest } from 'redux-saga/effects';
import { LOGIN_REQUEST } from '../actions/auth';
import { loginSaga } from './auth';
export default function* rootSaga() {
yield all([
takeLatest(LOGIN_REQUEST, loginSaga),
]);
}
```
---
## Définition des actions
```js
// src/actions/auth.js
export const LOGIN_REQUEST = 'LOGIN_REQUEST'
export const LOGIN_SUCCESS = 'LOGIN_SUCCESS';
export const LOGIN_FAILURE = 'LOGIN_FAILURE';
export function login(username, password) {
return { type: LOGIN_REQUEST, username, password }
}
export function loginFailure(username, error) {
return { type: LOGIN_FAILURE, username, error }
}
export function loginSuccess(username) {
return { type: LOGIN_SUCCESS, username }
}
```
---
## Création de ma saga d'authentification
```js
// src/sagas/auth.js
import { call, put } from 'redux-saga/effects';
import { loginFailure, loginSuccess } from '../actions/auth';
export default function* loginSaga(action) {
let result;
try {
result = yield call(doLogin, action.username, action.password);
} catch(err) {
yield put(loginFailure(action.username, err));
}
if ('error' in result) {
yield put(loginFailure(action.username, result.error));
return
}
yield put(loginSuccess(action.username));
}
function doLogin(username, password) {
return fetch('http://my-backend.org/login', {
method: 'POST',
body: JSON.stringify({
Username: username,
Password: password
}),
mode: 'cors',
credentials: 'include'
}).then(res => res.json())
}
```
---
## Mise en application
---
### Contexte
Implémenter une application de type "catalogue" comprenant les fonctionnalités suivantes:
- Créer/modifier/supprimer des fiches "produit"
- Trouver des produits via un système de recherche multicritères
- Un mécanisme d'authentification par mot de passe
- Un système de rôles basiques:
- Un rôle "administrateur" pouvant effectuer toutes les actions
- Un rôle "éditeur" pouvant créer/modifier/supprimer des fiches existantes
- Un rôle "consultant" permettant de lire/rechercher des fiches existantes
---
### Contraintes
Vous pouvez sélectionner n'importe quel type de produit à implémenter (album de musique, collection de pins, bestiaire de jeu de rôle...). Voici cependant les différents types d'attributs qui devront être présents sur une fiche produit:
- Un attribut de type numérique
- Un attribut de type "liste de mots clés"
- Un attribut de type "texte libre"
- Un attribut de type "fichier"
La partie "backend" est laissée à votre libre choix.
---
# Licence
## CC BY-NC-SA 3.0 FR
[Creative Commons - Attribution - Pas d’Utilisation Commerciale - Partage dans les Mêmes Conditions 3.0 France](https://creativecommons.org/licenses/by-nc-sa/3.0/fr/)