Nei precedenti articoli abbiamo parlato di Redux e abbiamo visto, nel dettaglio, come funziona. Ricapitolando, Redux fa uso di alcune funzioni chiamate Action creator che restituiscono un oggetto Action. Queste sono semplici oggetti javascript che devono contenere almeno una proprietà type e vengono passati, attraverso il metodo store.dispatch(), al Reducer. Il Reducer, che è una funzione pura e può essere scomposto in più funzioni Reducer, usa, a sua volta, l’Action ricevuta e l’oggetto State corrente per calcolare il nuovo valore dell’oggetto State stesso. L’oggetto State, mantenuto all’interno dell’oggetto Store, una volta "aggiornato", è pronto per essere usato. Tutte le funzioni (listener), che si erano registrate con l’oggetto Store per essere notificate ogni volta che è disponibile una nuova versione dell’oggetto State, vengono invocate. All’interno di queste funzioni è possibile accedere all’oggetto State, usando il metodo store.getState().
Usare Redux con React
Redux può essere facilmente usato con React. Potremmo per esempio creare un componente a cui potremmo passare l’oggetto Store restituito dalla funzione createStore() del modulo ‘redux’. A quel punto potremmo usare le funzioni store.subscribe(), store.dispatch() e store.getState() all’interno dei componenti React.
A semplificarci la vita, ci pensa react-redux che possiamo ovviamente aggiungere al nostro progetto col comando ‘npm install –save react-redux‘.
Nell’usare ‘react-redux’, distingueremo i componenti React in due categorie: Presentational Component e Container Component. In applicazioni di grandi dimensioni, il numero dei primi è superiore ai secondi.
Presentational Component
I Presentational Component (spesso vengono anche detti Dumb Component) vengono usati con l’unico scopo di mostrare a video delle informazioni. Non dipendono da Redux, sono completamente all’oscuro della presenza di Redux. Ricevono i dati dai Container Component tramite l’oggetto Props e, per lanciare delle Action o richiedere delle modifiche, invocano le funzioni che gli vengono passate dagli altri componenti, sempre attravero l’oggetto Props. Si tratta in sostanza di normalissimi componenti React come quelli che abbiamo creato nei precedenti articoli che si limitano semplicemente a mostrare le informazioni ricevute tramite l’oggetto Props.
Container Component
I Container Component sono il collante tra Redux e React. Vengono generati da React-redux sulla base di un componente da noi definito e ricevono i dati direttamente da Redux. React-redux passerà loro anche la funzione store.dispatch() in modo che possano lanciare direttamente delle Action. Questi componenti fanno da tramite fra Redux e i Presentational Components. Un Container Component altro non è se non un normale componente React che usa la funzione store.subscribe() per essere notificato della presenza di nuovi dati all’interno dell’oggetto State.
Componenti Misti
A volte la differenza tra i due componenti descritti sopra non è netta ed evidente, specialmente in applicazioni di piccole dimensioni. In questo caso è possibile unire in un unico componente sia le funzionalità dei Presentational che dei Container Component.
Come funziona React-redux
Nell’immagine qui in sopra abbiamo rappresentanto uno schema semplificato del funzionamento di Redux e React-redux. Nell’immagine a sinistra abbiamo i tre elementi fondamentali che caratterizzano Redux. A destra abbiamo invece introdotto un componente <Provider />, fornito da React-redux, che, insieme alla funzione connect(mapStateToProps, mapDispatchToProps)(Component), costituiscono i due ingredienti da usare per collegare React a Redux. <Provider store={store} /> riceve come attributo l’oggetto Store creato tramite la funzione createStore(rootReducer) e passerà l’oggetto store ricevuto ai Container Components. Per far ciò basterà usare <Provider store={store} /> come elemento radice della nostra applicazione e inserire gli altri elementi come discendenti dell’elemento <Provider />. Ciò che faremo, sarà creare un componente App che contiene gli altri componenti e passeremo l’elemento <App /> come immediato discendente di <Provider store={store} />.
// src/index.js
ReactDOM.render(
<Provider store={createStore(rootReducer)}>
<App />
</Provider>,
document.getElementById('root')
);
La funzione connect() restituisce una funzione che ci permette di creare un Container Component a partire da un determinato componente che passiamo come argomento. La funzione connect() può ricevere invece due argomenti che sono due riferimenti a due funzioni. La prima è la funzione mapStateToProps, attraverso la quale passiamo le proprietà dell’oggetto State (lo stato globale dell’applicazione restituito dal rootReducer) al Container Component tramite l’oggetto props. In questo modo, invece di dover invocare la funzione state.getState() e poi accedere alle diverse proprietà, accediamo alle proprietà dell’oggetto State usando semplicemente this.props.nome_proprietà. Per far ciò dovremo definire una funzione mapStateToProps come segue.
const mapStateToProps = (state, ownProps) => {
return {
nome_prop: state.nome_proprieta_oggetto_state_globale
}
}
React-redux passerà alla funzione mapStateToProps l’oggetto state corrente che sarebbe l’oggetto restituito da quello che nei precedenti articoli abbiamo definito rootReducer.
Abbiamo detto che connect() restituisce una funzione a cui passeremo il riferimento a un componente che abbiamo definito (per comodità faremo riferimento a questo componente con il nome "FuturoContainer") e che vogliamo ‘convertire’ in Container Component.
const Container = connect(
mapStateToProps,
mapDispatchtoProps)(FuturoContainer);
Tornando a parlare di mapStateToProps, il secondo argomento di questa funzione, è proprio l’oggetto Props che verrà eventualmente passato al componente Container. (vedi il codice qui sopra) Le proprietà dell’oggetto restituito dalla funzione mapStateToProps saranno invece accessibili all’interno del componente FuturoContainer attraverso l’oggetto Props. Quindi quando definiremo FuturoContainer potremo accedere alla proprietà nome_prop attraverso this.props.nome_prop. In questo modo possiamo accedere alle proprietà dell’oggetto State globale semplicemente usando l’oggetto Props.
Allo stesso modo definiremo la funzione mapDispatchToProps() che restituirà un oggetto le cui proprietà saranno disponibili all’interno di "FuturoContainer" che potrà così lanciare un’Action semplicemente invocando una funzione passata tramite l’oggetto Props.
const mapDispatchToProps = (dispatch, ownProps) => {
return {
lanciaAction: () => {
dispatch(creaAction)
}
}
}
In questo modo, in fase di definizione del Componente "FuturoContainer", potremo lanciare l’Action restituita da creaAction, semplicemente invocando this.props.lanciaAction.
Un Esempio pratico con React-redux
Vediamo un esempio per cercare di capire meglio come funziona quanto detto finora. Consideriamo sempre il solito esempio visto negli ultimi due articoli.
Rispetto a quanto visto nei precedenti articoli, modificheremo leggermente l’oggetto State e di conseguenza le funzioni Reducer.
Creiamo intanto una nuova applicazione con create-react-app. La struttura della directory del nostro progetto sarà come la seguente.
Modifichiamo innanzitutto il file index.js presente all’interno della directory src con il seguente codice.
// file src/index.js
import React from 'react';
import ReactDOM from 'react-dom';
import { Provider } from 'react-redux';
import { createStore } from 'redux';
import rootReducer from './reducers';
import App from './App';
const initialState = {
currentDriver: "",
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
}
}
}
// abilitiamo Redux Dev Tools
const store = createStore(
rootReducer,
initialState,
window.__REDUX_DEVTOOLS_EXTENSION__ && window.__REDUX_DEVTOOLS_EXTENSION__()
);
ReactDOM.render(
<Provider store={store}>
<App />
</Provider>,
document.getElementById('root')
)
Nel file src/index.js invochiamo ReactDOM.render() e passiamo come React Element l’elemento <Provider /> in cui abbiamo inserito l’elemento <App />. Passiamo lo store all’elemento <Provider /> che provvederà a renderlo disponibile ai Container Component creati con la funzione connect(). Lo store è stato generato con la funzione createStore() a cui passiamo come primo argomento il rootReducer, il secondo argomento è lo stato iniziale dell’applicazione, il terzo argomento ci permette di abilitare Redux DevTools.
Useremo l’estensione di Chrome di Redux DevTools. Alla pagina github del progetto trovate tutte le informazioni su come configurare e avviare questo strumento che ci permetterà, fra le tante altre cose, di tenere traccia di tutte le Action lanciate, visualizzare l’oggetto State in diversi formati, lanciare delle Action e ripetere tutta la sequenza di Action lanciate.
Come potete notare, abbiamo cambiato la struttura dell’oggetto state dell’applicazione rispetto agli articoli precedenti.
Nel file src/App.js abbiamo creato invece un semplice componente che restituirà un React Element con la struttura complessiva della nostra semplice applicazione in cui sono presenti due Container Components: una lista di piloti e la scheda con i dettagli di ciascun pilota.
// file src/App.js
import React from 'react';
import Details from './containers/DriverDetailsContainer';
import Drivers from './containers/DriverListContainer';
import './App.css';
const App = () => {
return (
<div className="app-root">
<Drivers />
<Details />
</div>
);
}
export default App;
All’interno della directory src/actions abbiamo invece inserito un file index.js con il codice delle tre funzioni Action Creator che useremo. Inserendo il contenuto delle funzioni all’interno di un file index.js, quando includeremo il modulo con il comando import, potremo semplicemente specificare il percorso della directory src/actions e verrà importato in automatico ciò che è stato esportato nel file index.js.
// file src/actions/index.js
import { ADD_DRIVER, CURRENT_DRIVER, REMOVE_DRIVER } from '../actionTypes';
/*
* @param {Object} driver
*/
export const addDriver = (driver) => {
return {
type: ADD_DRIVER,
driver
}
}
/*
* @param {String} driverName
*/
export const changeDriver = (driverName) => {
return {
type: CURRENT_DRIVER,
driver: driverName
}
}
/*
* @param {String} driverName
*/
export const removeDriver = (driverName) => {
return {
type: REMOVE_DRIVER,
driver: driverName
}
}
Abbiamo esportato tutte e tre le Action. L’argomento driver della funzione addDriver sarà un oggetto javascript come il seguente.
const driver = {
"Fernando Alonso": {
Team: "McLaren",
Country: "Spain",
Podiums: 97,
Points: 1832,
'World Championships': 2
}
}
All’interno della directory src/actionTypes troveremo anche qui un file index.js in cui abbiamo definito delle costanti che abbiamo usato per creare le Action e useremo nella dichiarazione dei Reducer.
// file /src/actionTypes/index.js
export const ADD_DRIVER = 'ADD_DRIVER';
export const REMOVE_DRIVER = 'REMOVE_DRIVER';
export const CURRENT_DRIVER = 'CURRENT_DRIVER';
Esaminiamo ora invece il codice dei due Container Component: DriverListContainer e DriverDetailsContainer.
// file containers/DriverListContainer.js
import { connect } from 'react-redux';
import { changeDriver } 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))
}
}
}
const Drivers = connect(mapStateToProps, mapDispatchToProps)(DriverList);
export default Drivers;
// file containers/DriverDetailsContainer.js
import { connect } from 'react-redux';
import DriverDetails from '../components/DriverDetails';
const mapStateToProps = (state, ownProps) => {
return {
currentDriver: state.currentDriver,
drivers: state.drivers
}
}
const Details = connect(mapStateToProps)(DriverDetails);
export default Details;
Grazie agli oggetti restituiti dalle funzioni mapStateToProps e mapDispatchToProps, potremo accedere, all’interno dei componenti DriverDetails e DriverList, alle proprietà currentDriver e drivers dell’oggetto State globale usando semplicemente l’oggetto Props di ciascun componente. Inoltre, all’interno del componente DriverList avremo la possibilità di lanciare l’Action restituita dalla funzione changeDriver(driver) definita nel file src/actions/index.js semplicemente eseguendo la funzione this.props.chooseDriver.
Abbiamo poi definito i componenti DriverDetails, DriverList e ListItem in cui sostanzialmente non c’è nulla di nuovo rispetto a quanto abbiamo visto nei precedenti articoli.
// 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);
console.log(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;
All’interno di DriverDetails verifichiamo che almeno un pilota della lista sia stato selezionato e mostriamo i dettagli del pilota recuperandoli dalla proprietà drivers dell’oggetto globale State a cui abbiamo accesso tramite this.props.drivers.
// 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;
All’interno di DriverList abbiamo usato ListItem che abbiamo definito nel seguente modo.
// file src/components/ListItem.js
import React, {Component} from 'react';
class ListItem extends Component {
onClick = () => this.props.onClick(this.props.name);
render() {
const active = this.props.active;
return (
<li>
<button
className={active ? "btn--active" : "btn"}
onClick={this.onClick}>{this.props.name}</button>
</li>
)
}
}
export default ListItem;
Nel componente ListItem abbiamo usato la funzionalità di ES7 (ES7 Property Initializer) per definire la funzione onClick.
Fin qui nulla di complicato. Passiamo ora ad esaminare i Reducer che sono quelli che ci permettono di ‘aggiornare’ lo stato dell’applicazione.
All’interno del file src/reducers/index.js abbiamo definito il rootReducer usando la funzione combineReducer. (per chiarimenti leggete gli articoli precedenti su Redux)
// file src/reducers/index.js
import {combineReducers} from 'redux';
import selectDriver from './reducer_select_driver';
import drivers from './reducer_drivers';
const rootReducer = combineReducers({
currentDriver: selectDriver,
drivers
});
export default rootReducer;
Abbiamo quindi due funzioni Reducer che vengono combinate per formare il rootReducer, la prima è la funzione selectDriver.
// file src/reducers/reducer_select_driver.js
import { CURRENT_DRIVER, REMOVE_DRIVER } from '../actionTypes';
function selectDriver(lastDriverSelected = '', action) {
if (action.type === CURRENT_DRIVER) {
return action.driver;
}
if (action.type === REMOVE_DRIVER
&& action.driver === lastDriverSelected) {
return '';
}
return lastDriverSelected;
}
export default selectDriver;
Se l’azione ricevuta è CURRENT_DRIVER, ovvero se stiamo selezionando un pilota della lista, la funzione restituisce una stringa corrispondente al nome del pilota selezionato. Se viene richiesto di rimuovere dalla lista il pilota attualmente selezionato, allora restituiremo una stringa vuota. In tutti gli altri casi ci limitiamo a restituire il nome dell’ultimo pilota selezionato.
Il Reducer drivers è leggermente più complicato. Ricordiamo che nell’oggetto State, la proprietà drivers è un oggetto in cui ogni proprietà è il nome di un pilota. A ciascun pilota corrisponde un oggetto con tutti i dettagli sul pilota stesso. Riportiamo nuovamente l’oggetto initialState visto nel file src/index.js qui sotto.
const state = {
currentDriver: "",
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
}
}
}
Vediamo allora il codice del Reducer src/reducers/reducer_drivers.js.
// file src/reducers/reducer_drivers.js
import { ADD_DRIVER, REMOVE_DRIVER } from '../actionTypes';
function drivers(previousListOfDrivers = {}, action) {
switch(action.type) {
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;
Nel caso venga richiesto di aggiungere un pilota (Action ADD_DRIVER), ci limitiamo a restituire un nuovo oggetto in cui copiamo il contenuto del vecchio oggetto state.driver e aggiungiamo il nuovo oggetto ricevuto dall’oggetto Action. Il nuovo oggetto ottenuto avrà quindi tutti i piloti presenti nel vecchio oggetto più il nuovo pilota ricevuto inseguito alla ricezione dell’Action ADD_DRIVER.
Se riceviamo una richiesta di rimozione di un pilota, tramite Object.keys(previousListOfDrivers) otteniamo un array contenente tutte le proprietà di previousListOfDrivers, ovvero dell’oggetto state.driver. (Object.keys(previousListOfDrivers) quindi sarà un array contenente il nome dei piloti). Usiamo quindi la funzione Array.prototype.reduce() per costruire un oggetto avente tutti gli elementi della precedente lista di piloti tranne quello che è stato chiesto di rimuovere. Abbiamo invocato la funzione (reduce(func(prevObj, key), {})) passando come primo argomento una funzione e secondo argomento un oggetto vuoto. Alla prima iterazione l’argomento prevObj della funzione sarà pari all’oggetto {}. L’argomento key sarà ogni volta uguale a uno dei valori contenuti nell’array. Ad ogni iterazione, inseriamo nell’oggetto accumulatore prevObj una proprietà corrispondente al nome di un pilota e assegniamo l’oggetto con i dettagli del pilota a questa proprietà. Eseguiamo la procedura appena illustrata per ogni pilota, escludendo però il pilota che vogliamo eliminare. Alla fine, restituiamo il nuovo oggetto privato del pilota il cui nome è stato ricevuto tramite action.driver.
Per come è stata implementata, l’applicazione consente solo di selezionare un pilota dalla lista. Non sono state implementate le funzionalità per aggiungere o rimuovere un pilota.
Per verificare che i Reducer siano stati implementati correttamente possiamo scrivere dei test, ma in questa guida non tratteremo l’argomento. Useremo invece Redux DevTools per lanciare sia un’Action ADD_DRIVER che REMOVE_DRIVER.
All’interno di Redux DevTools proviamo allora a lanciare la seguente Action.
{
type: 'REMOVE_DRIVER',
driver: 'Sebastian Vettel'
}
Proviamo quindi ad aggiungere "Fernando Alonso" all’elenco dei piloti.
E per concludere rivediamo tutte le Action lanciate dall’inizio.
Come potete vedere Redux DevTools è uno strumento estremamente utile e anche diverte da usare. Nel prossimo articolo vedremo cosa sono e come usare i middleware in Redux.