Nella precedente lezione abbiamo visto cosa sono e come usare i middleware. Illustreremo ora qualche altro esempio di middleware, in particolare affronteremo il caso in cui sia necessario gestire delle Action asincrone.
Finora abbiamo creato delle funzioni Action Creator che restituiscono degli oggetti corrispondenti alle Action che vogliamo lanciare. In alcune situazioni non è però possibile restituire un semplice oggetto. Ciò accade per esempio nel caso venga lanciata un’Action per recuperare dei dati da un server.
Middleware per Action asincrone
Consideriamo l’esempio visto nei precedenti articoli della lista dei piloti in cui è possibile selezionare un pilota per visualizzare una scheda dettagliata su di lui. Apriamo la directory base di quel progetto e continuiamo a lavorare da dove eravamo rimasti nei precedenti articoli.
Finora abbiamo inizializzato la lista dei piloti attraverso l’uso di un oggetto initialState, passato nel file src/index.js alla funzione createStore.
Supponiamo, invece, di voler recuperare da un server la lista iniziale dei piloti. Useremo per semplicità il package json-server per creare un server fittizio. Installiamo json-server e concurrently.
npm install --save concurrently json-server
Modifichiamo il file package.json. Abbiamo cambiato gli scripts in modo da lanciare contemporaneamente json-server e la nostra applicazione.
{
"name": "react-redux-example",
"version": "0.1.0",
"private": true,
"dependencies": {
"concurrently": "^3.4.0",
"json-server": "^0.10.0",
"react": "^15.5.4",
"react-dom": "^15.5.4",
"react-redux": "^5.0.4",
"redux": "^3.6.0"
},
"devDependencies": {
"react-scripts": "0.9.5"
},
"scripts": {
"start": "concurrently --kill-others "npm run start-react" "npm run json-server"",
"start-react": "react-scripts start",
"build": "react-scripts build",
"test": "react-scripts test --env=jsdom",
"eject": "react-scripts eject",
"json-server": "json-server -p 3001 -d 3000 --watch db.json"
}
}
Creeremo quindi un file db.json nella directory base del nostro progetto.
// file: db.json
{
"drivers": {
"Sebastian Vettel": {
"Team": "Ferrari",
"Country": "Germany",
"Podiums": 89,
"Points": 2176,
"World Championships": 4
},
"Lewis Hamilton": {
"Team": "Mercedes",
"Country": "United Kingdom",
"Podiums": 107,
"Points": 2308,
"World Championships": 3
},
"Max Verstappen": {
"Team": "Red Bull",
"Country": "Netherlands",
"Podiums": 8,
"Points": 278,
"World Championships": 0
}
}
}
Modifichiamo poi il file src/index.js.
import React from 'react';
import ReactDOM from 'react-dom';
import { Provider } from 'react-redux';
import { createStore, applyMiddleware, compose } from 'redux';
import rootReducer from './reducers';
// importiamo i middleware
import { simpleLogger, noRemove, handlePromise } from './middlewares';
import App from './App';
// usiamo Redux DevTools Extension come mostrato nella documentazione
const composeEnhancers = window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ || compose;
// aggiungiamo handlePromise ai middleware
const store = createStore(
rootReducer,
composeEnhancers(applyMiddleware(simpleLogger, noRemove, handlePromise))
);
ReactDOM.render(
<Provider store={store}>
<App />
</Provider>,
document.getElementById('root')
)
Notate che abbiamo aggiunto un nuovo middleware handlePromise e abbiamo rimosso l’oggetto initialState in quanto vogliamo recuperare dal server l’oggetto con cui inizializzare la proprietà drivers dell’oggetto State globale.
Per far ciò abbiamo creato una nuova funzione all’interno del file network.js nella directory utils che abbiamo aggiunto al progetto.
const baseURL = "http://localhost:3001/drivers";
export const getAll = () => fetch(baseURL).then(res => res.json());
Per semplicità abbiamo trascurato ogni forma di controllo di eventuali errori. Ci limitiamo a recuperare la risorsa dal server e a restituire una Promise.
Creiamo poi una nuova funzione Action Creator nel file actions/index.js in cui usiamo la costante RECEIVE_DRIVERS definita all’interno del file actionTypes/index.js
export const getDrivers = () => {
return getAll().then(drivers => {
return {
type: RECEIVE_DRIVERS,
drivers
}
});
}
Come potete notare la funzione creata restituisce una Promise e non il solito oggetto Action.
Modifichiamo quindi il reducer drivers definito nel file reducers/reducer_drivers.js. Nel caso in cui venga lanciata un’Action con una proprietà type uguale a RECEIVE_DRIVERS, alla proprietà drivers dell’oggetto State verrà assegnato l’oggetto ricevuto dal server contenente la lista dei piloti.
// file: reducers/reducer_drivers.js
import { ADD_DRIVER,
REMOVE_DRIVER,
RECEIVE_DRIVERS } from '../actionTypes';
function drivers(previousListOfDrivers = {}, action) {
switch(action.type) {
case RECEIVE_DRIVERS:
return action.drivers;
case ADD_DRIVER:
return {
...previousListOfDrivers,
...action.driver
}
case REMOVE_DRIVER:
return Object.keys(previousListOfDrivers)
.reduce((prevObj, key) => {
if (key !== action.driver) {
prevObj[key] = previousListOfDrivers[key];
}
return prevObj;
}, {});
default:
return previousListOfDrivers;
}
}
export default drivers;
Modifichiamo quindi la funzione mapStateToProps contenuta nel file containers/DriverListContainer.js. Attraverso l’oggetto Props, passeremo un nuovo valore al componente DriverList ovvero la funzione getDrivers la quale ci permetterà di lanciare l’Action restituita dalla funzione Action Creator che abbiamo appena aggiunto. Ricordate però che la nuova funzione creata non restituisce un oggetto Action ma una Promise.
// file: containers/DriverListContainer.js
import { connect } from 'react-redux';
import { changeDriver, getDrivers } from '../actions';
import DriverList from '../components/DriverList';
const mapStateToProps = (state, ownProps) => {
return {
currentDriver: state.currentDriver,
drivers: state.drivers
}
}
const mapDispatchToProps = (dispatch, ownProps) => {
return {
chooseDriver: (driver) => {
dispatch(changeDriver(driver))
},
// abbiamo aggiunto una nuova proprietà
getDrivers: () => {
dispatch(getDrivers());
}
}
}
const Drivers = connect(mapStateToProps, mapDispatchToProps)(DriverList);
export default Drivers;
Per semplicità affideremo al componente components/DriverList il compito di invocare la funzione getDrivers per recuperare i dati iniziali dal server che abbiamo avviato grazie a json-server.
// file: components/DriverList.js
import React, {Component} from 'react';
import ListItem from './ListItem';
class DriverList extends Component {
componentDidMount() {
this.props.getDrivers();
}
list() {
return Object.keys(this.props.drivers).map(driver => (
>ListItem
key={driver}
name={driver}
active={driver === this.props.currentDriver}
onClick={this.props.chooseDriver}
/>
));
}
render() {
return (
>ul className="drivers-list">
{this.list()}
>/ul>
)
}
}
export default DriverList;
All’interno del metodo componentDidMount() (per maggiori dettagli leggete l’articolo sul Ciclo di vita di un componente) invochiamo la funzione getDrivers() per effettuare la richiesta al server. Ricordate che mediante la funzione mapStateToProps, la funzione this.props.getDrivers() usa la funzione dispatch() per lanciare un’Action.
Per concludere, vediamo come abbiamo realizzato il middleware handlePromise che è essenziale al funzionamento dell’applicazione, dato che la funzione Action Creator getDrivers() che abbiamo creato restituisce una Promise e non un oggetto Action. Se non usassimo il middleware handlePromise, l’applicazione stamperebbe nella console il seguente errore.
Vediamo allora finalmente la funzione handlePromise che abbiamo definito all’interno del file middleware/index.js
export const handlePromise = store => next => action => {
if (action && typeof action.then === 'function') {
return Promise.resolve(action).then(store.dispatch);
}
return next(action);
}
All’interno del nostro semplice middleware, verifichiamo che l’argomento action ricevuto sia una Promise. In tal caso, quando la Promise viene risolta, passiamo il valore (che è proprio l’oggetto Action {type: RECEIVE_DRIVERS, drivers}) alla funzione store.dispatch(action) che ci permetterà di far ripercorrere all’Action tutta la catena dei middleware. In questo modo, tutti i middleware che precedono handlePromise possono finalmente ricevere l’oggetto Action. Avendo rilanciato l’Action, handlePromise sarà nuovamente invocata ricevendo, questa volta, l’oggetto Action che aveva precedentemente passato alla funzione store.dispatch(). A questo punto handlePromise passerà l’action al prossimo Middleware invocando next(action).
Notate il primo rigo nell’immagine in cui è scritto ‘undefined’. È il middleware simpleLogger che abbiamo realizzato nel precedente articolo. La funzione simpleLogger si limitava a stampare dei messaggi nella console. In quel caso passavamo alla funzione console.group() il valore di action.type, ma, come è facile immaginare, questa proprietà non è definita nella Promise che simpleLogger riceve in seguito all’esecuzione della funzione this.props.getDrivers() nel componente DriverList. Quando la Promise viene passata a handlePromise, per la prima volta, invochiamo store.dispatch(action) nella quale l’Action è a questo punto un oggetto che ripercorre tutti i precedenti middleware.
Action asincrone con Redux-thunk
Abbiamo visto come lanciare delle Action in maniera asincrona sfruttando i middleware. In questo caso abbiamo scritto la nostra funzione handlePromise. È possibile trovare vari middleware su NPM che ci permettono di gestire Action asincrone. Uno dei più interessanti è redux-thunk. Redux-thunk è una funzione come quella mostrata qui sotto.
// Codice della funzione thunk presente
// nella pagina http://redux.js.org/docs/advanced/Middleware.html
const thunk = store => next => action =>
typeof action === 'function' ?
action(store.dispatch, store.getState) :
next(action)
// oppure usando ES5
var thunk = function thunk(store) {
return function (next) {
return function (action) {
if (typeof action === 'function') {
return action(store.dispatch, store.getState);
}
return next(action);
};
};
};
Per usare Redux-thunk, all’interno delle funzioni Action Creator dovremo restituire una funzione che riceverà come argomenti un riferimento alla funzione store.dispatch e store.getState – Dovremo quindi passare redux-thunk alla funzione applyMiddleware() e modificare la funzione actionCreator getDrivers.
// nuova funzione getDrivers da usare insieme a redux-thunk
export const getDrivers = () => {
return function(dispatch, getState) {
return getAll().then(drivers => {
const fetchAction = {
type: RECEIVE_DRIVERS,
drivers
};
dispatch(fetchAction);
});
};
}
Questa volta, la funzione getDrivers() restituirà una nuova funzione che verrà invocata nel redux-thunk middleware. Anche in questo caso, quando la Promise restituita da getAll() sarà risolta, verrà costruito l’oggetto Action che successivamente sarà passato alla funzione dispatch(action).
Esempio lista piloti con Redux-thunk
Effettuando delle leggere modifiche all’applicazione, potremmo mostrare agli utenti un messaggio per avvisarli che stiamo recuperando delle informazioni dal server ed è necessario aspettare che le risorse vengano scaricate.
Modifichiamo allora la struttura del progetto come segue.
Abbiamo rimosso i vecchi Container Components e ne abbiamo introdotto uno nuovo insieme a un componente Wrapper. Abbiamo quindi aggiunto un nuovo reducer (reducer_loading.js) e alcune nuove funzioni Action Creator. Analizziamo quindi il nuovo codice partendo dal componente App.js.
// file: src/App.js
import React from 'react';
import WrapperContainer from './containers/WrapperContainer';
import './App.css';
const App = () => {
return <WrapperContainer />;
}
export default App;
Il nuovo componente App si limita a restituire un semplice elemento <WrapperContainer /> a cui abbiamo affidato il compito di collegare Redux al resto dell’applicazione.
// file: src/containers/WrapperContainer.js
import { connect } from 'react-redux';
import { changeDriver, getDrivers } from '../actions';
import Wrapper from '../components/Wrapper';
const mapStateToProps = (state, ownProps) =< {
return {
currentDriver: state.currentDriver,
drivers: state.drivers,
loading: state.loading
}
}
const mapDispatchToProps = (dispatch, ownProps) =< {
return {
chooseDriver: (driver) =< {
dispatch(changeDriver(driver))
},
getDrivers: () =< {
dispatch(getDrivers());
}
}
}
const WrapperContainer = connect(mapStateToProps, mapDispatchToProps)(Wrapper);
export default WrapperContainer;
All’interno del componente Wrapper, passeremo poi le varie Props (currentDriver, drivers, loading) agli altri componenti DriverList e DriverDetails.
// file: src/components/Wrapper.js
import React, { Component } from 'react';
import DriverList from './DriverList';
import DriverDetails from './DriverDetails';
class Wrapper extends Component {
componentDidMount() {
this.props.getDrivers();
}
render() {
const loading = this.props.loading;
let messageClassList;
let appClassList;
let toBeRendered;
if (loading) {
messageClassList = 'app-loading app-loading--visible';
appClassList = 'app-content app-content--hidden';
} else {
messageClassList = 'app-loading app-loading--hidden';
appClassList = 'app-content app-content--visible';
}
return (
<div className=<app-root<>
<span className={ messageClassList } >
loading...Please wait.
</span>
<div className={ appClassList }>
<DriverList
drivers={this.props.drivers}
chooseDriver={this.props.chooseDriver}
currentDriver={this.props.currentDriver} />
<DriverDetails
drivers={this.props.drivers}
currentDriver={this.props.currentDriver} />
</div>
</div>
);
}
}
export default Wrapper;
Se il valore della variabile loading (riflette il valore della proprietà loading dell’oggetto globale state) è true ovvero se l’applicazione non ha ancora ricevuto i dati dal server, mostriamo un messaggio per informare gli utenti che l’applicazione deve completare la fase di caricamento. Non appena riceviamo i dati dal server nascondiamo il messaggio "Loading…Please wait." e mostriamo la lista dei piloti.
// file: src/components/DriverList.js
import React, {Component} from 'react';
import ListItem from './ListItem';
class DriverList extends Component {
list() {
return Object.keys(this.props.drivers).map(driver => (
<ListItem
key={driver}
name={driver}
active={driver === this.props.currentDriver}
onClick={this.props.chooseDriver}
/>
));
}
render() {
return (
<ul className="drivers-list">
{this.list()}
</ul>
)
}
}
export default DriverList;
// file: src/components/DriverDetails.js
import React, {Component} from 'react';
class DriverDetails extends Component {
render() {
const currentDriver = this.props.currentDriver;
if (currentDriver === ''){
return <div className="driver-details"><p>Select a Driver from the list</p></div>
}
const driverDetails = this.props.drivers[currentDriver];
const driverDetailsKeys = Object.keys(driverDetails);
const tableContent = driverDetailsKeys.map(key => (
<tr key={key}>
<th>{key}</th>
<td>{driverDetails[key]}</td>
</tr>
));
return (
<div className="driver-details">
<h2>{currentDriver}</h2>
<table>
<tbody>
{tableContent}
</tbody>
</table>
</div>
)
}
}
export default DriverDetails;
Abbiamo quindi aggiunto un nuovo reducer. Se riceviamo un’action con proprietà type pari a RECEIVE_DRIVERS, ovvero se abbiamo ricevuto i dati dal server con successo, settiamo la proprietà loading dell’oggetto state globale pari a false. Se invece siamo in attesa di ricevere le informazioni (prima di richiedere i dati al server lanciamo un’Action FETCH_DRIVERS), state.loading sarà pari a true. In questo modo all’interno del componente Wrapper possiamo mostrare un messaggio agli utenti per informali che è necessario aspettare il termine della fase di inizializzazione dell’App.
// file: src/reducers/reducer_loading.js
import { FETCH_DRIVERS, RECEIVE_DRIVERS } from '../actionTypes';
function loading(prevLoadingStatus = false, action) {
switch(action.type) {
case RECEIVE_DRIVERS:
return false;
case FETCH_DRIVERS:
return true;
default:
return prevLoadingStatus;
}
}
export default loading;
// file: src/reducers/index.js
import {combineReducers} from 'redux';
import selectDriver from './reducer_select_driver';
import drivers from './reducer_drivers';
import loading from './reducer_loading';
const rootReducer = combineReducers({
currentDriver: selectDriver,
drivers,
loading
});
export default rootReducer;
All’interno del file actions/index.js abbiamo modificato l’Action Creator getDrivers() come segue.
// file: src/actions/index.js
import { ADD_DRIVER,
CURRENT_DRIVER,
REMOVE_DRIVER,
RECEIVE_DRIVERS,
FETCH_DRIVERS } from '../actionTypes';
import { getAll } from '../utils/network';
export const fetchDrivers = () =< ({type: FETCH_DRIVERS});
export const receiveDrivers = (drivers) =< {
return {
type: RECEIVE_DRIVERS,
drivers
};
}
export const getDrivers = () =< {
return function(dispatch, getState) {
dispatch(fetchDrivers());
return getAll().then(drivers =< {
dispatch(receiveDrivers(drivers));
});
};
}
export const addDriver = (driver) =< {
return {
type: ADD_DRIVER,
driver
}
}
export const changeDriver = (driverName) =< {
return {
type: CURRENT_DRIVER,
driver: driverName
}
}
export const removeDriver = (driverName) =< {
return {
type: REMOVE_DRIVER,
driver: driverName
}
}
Quando viene invocata la funzione getDrivers(), questa restituisce una nuova funzione che viene invocata all’interno del middleware redux-thunk. Viene innanzitutto lanciata l’Action restituita dalla funzione fetchDrivers in modo che il reducer loading cambi il valore della proprietà loading dell’oggetto state globale (state.loading assumerà valore true) e una volta ricevuti i dati dei piloti lanceremo l’action restituita da receiveDrivers() per segnalare che la fase di caricamento iniziale è terminata.
Siamo giunti al termine di questo articolo e della guida, spero che sia stata utile e abbia fornito delle informazioni interessanti. A questo punto spero abbiate acquisito le nozioni essenziali per potervi divertire a realizzare delle applicazioni. Happy Coding!: D