--- 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 (

Hello World

Welcome to React

) } } export default HelloWorld ``` --- ## Notation fonctionnelle ```jsx // my-app/src/components/hello-world.js const HelloWorld = () => { return (

Hello World

Welcome to React

) } 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 ( ) } } 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/)