Nella lezione precedente abbiamo fatto una panoramica di Redux elencandone i principi fondamentali. In questo articolo cercheremo di analizzare in dettaglio come funziona Redux. Vedremo prima quali sono gli elementi essenziali per usare Redux nelle nostre applicazioni. In Redux possiamo infatti distinguere tre componenti: Action, Reducer, Store. Vediamo allora di cosa si tratta e come sono fra loro correlati.
Primo elemento: Action
Partiamo con le Action che sono dei semplici oggetti javascript utilizzati per inviare informazioni allo Store. Si tratta dell’unico mezzo attraverso il quale si può richiedere l’aggiornamento delle informazioni presenti nello Store che è l’oggetto che mantiene lo stato dell’intera applicazione. L’unico requisito delle Action è che devono essere un oggetto contentente una proprietà type che è la sola proprietà necessaria affinché un oggetto possa definirsi un’Action. Oltre alla proprietà type, possiamo aggiungere altre proprietà, a seconda delle necessità e delle informazioni che vogliamo passare ai Reducer che sono i destinatari finali delle Action.
Vediamo come definire un’Action. Riprendiamo l’esempio con cui abbiamo terminato il precedente articolo e modifichiamolo leggermente. Ricapitolando, immaginiamo di avere una semplice applicazione in cui abbiamo un elenco di piloti. Cliccando sul nome di un pilota, visualizziamo la sua scheda dettagliata.
Per prima cosa creeremo un file actionTypes.js in cui definiamo i nomi delle diverse Action. È opportuno definire i nomi delle diverse Action come costanti all’interno di un file per evitare errori nella scrittura e semplificare lo sviluppo delle applicazioni. Questo accorgimento può salvare tempo soprattutto quando si realizzano applicazioni più complicate.
// actionTypes.js
export const CURRENT_DRIVER = 'CURRENT_DRIVER';
A questo punto definiamo le diverse Action in un file che chiameremo action.js. Potremmo creare un semplice oggetto come il seguente.
{
type: 'CURRENT_DRIVER'
}
Ma se ricordate, nell’esempio precedente avevamo aggiunto un’altra proprietà driver che verrà usata per selezionare un preciso pilota della lista. Rispetto al precedente articolo, nel selezionare un pilota, passeremo, alla proprietà driver dell’Action, un riferimento all’intero oggetto contenente le informazioni del pilota. Avremo quindi un oggetto come quello mostrato qui sotto.
{
type: 'CURRENT_DRIVER',
driver: {name: "Nome del Pilota", team: "Scuderia del Pilota"}
}
Risulta allora evidente che è necessario utilizzare delle funzioni per creare le Action in maniera dinamica. All’interno del file action.js inseriremo quelli che Redux definisce Action creators. Si tratta di funzioni che restituiscono un’azione. Definiamo allora una funzione chooseDriver(driver).
import { CURRENT_DRIVER } from './actionTypes.js';
/*
* @param {Object} driver - il pilota selezionato all'interno della lista
* @returns {Object} - l'oggetto Action
*/
function chooseDriver(driver) {
return {
type: CURRENT_DRIVER,
driver // è equivalente a scrivere driver: driver
}
}
Per inviare l’oggetto Action allo Store e quindi passarlo ai Reducer invocheremo il metodo store.dispatch(). Per esempio, all’interno della nostra applicazione chiameremo la funzione dispatch() nel seguente modo. (Abbiamo supposto che driver sia una variabile definita da qualche parte.)
// ...
store.dispatch(chooseDriver(driver));
All’interno del metodo store.dispatch(), l’Action, passata come argomento, verrà a sua volta passata ai Reducer.
Secondo elemento: i Reducer
I Reducer sono le uniche funzioni autorizzate ad aggiornare le informazioni contenute nello Store il quale è l’oggetto predisposto a mantenere lo stato dell’intera applicazione.
È importante sottolineare che le funzioni Reducer non modificano l’oggetto State, ricevono in ingresso l’oggetto State corrente e restituiscono un nuovo oggetto State. A tal fine, nel caso si debba lavorare con oggetti o array, può essere utile usare la funzione Object.assign() e l’operatore spread (…). Di estremo interesse sono anche librerie come Immutable che però non tratteremo in questi articoli.
Dati gli argomenti in ingresso, una funzione Reducer deve calcolare il prossimo valore dell’oggetto State e restituirlo.
I Reducer, essendo funzioni pure, non devono mai modificare l’oggetto State, verrà sempre restituito un nuovo oggetto.
Una volta definito quale sarà l’oggetto State che rappresenta lo stato globale dell’applicazione, possiamo definire la funzione Reducer che potrà aggiornarlo. Possiamo usare due approcci. Creare un Reducer e poi eventualmente riorganizzare il codice e scomporre tale Reducer in più funzioni. Oppure creare una funzione Reducer per ogni pezzo dell’oggetto State e poi combinare tali funzioni in un’unico Reducer.
Sulla documentazione di Redux è utilizzato un approccio che possiamo classificare come top-down. Viene cioè definito un generico Reducer che viene poi scomposto in più reducer ciascuno dei quali gestisce una parte dello stato complessivo. Al fine di spiegare come funzionano i Reducer, può risultare però più semplice usare una tecnica bottom-up. Potete comunque consultare la documentazione ufficiale per ulteriori spiegazioni. Consideriamo intanto come sarà la struttura dell’oggetto State della nostra applicazione.
{
currentDriver: {name: "Lewis Hamilton", team: "Mercedes"},
drivers: [
{
name: "Sebastian Vettel",
team: "Ferrari"
},
{
name: "Lewis Hamilton",
team: "Mercedes"
},
{
name: "Max Verstappen",
team: "Red Bull Racing"
}
]
}
Abbiamo detto nel precedente articolo che è possibile combinare più Reducer insieme. Allora, per ogni proprietà dell’oggetto State che vogliamo aggiornare, definiamo una funzione Reducer che avrà l’incarico di gestire solo quella singola parte dell’intero oggetto State. In questo particolare caso, creiamo un Reducer per ogni proprietà, per mostrare come sia possibile combinarli insieme per creare un unico Reducer. È comunque facoltà del programmatore capire e decidere come e se comporre o scomporre i diversi Reducer. Alla fine ciò che dovrà essere creato è un singolo Reducer (chiameremo questo Reducer col nome rootReducer) che si occuperà di aggiornare l’intero oggetto State dell’applicazione e che verrà passato alla funzione createStore(rootReducer) per creare lo Store. (Ne parleremo a breve)
Definiamo allora le seguenti funzioni.
function selectDriver(lastDriverSelected = {}, action) {
if (action.type === 'CURRENT_DRIVER') {
return action.driver;
}
if (
action.type === 'REMOVE_DRIVER' &&
action.driver.name === lastDriveSelected.name
) {
return {};
}
return lastDriverSelected;
}
function drivers(previousListOfDrivers = [], action) {
switch(action.type) {
case 'ADD_DRIVER':
return [
...previousListOfDrivers,
{
name: action.driver.name,
team: action.driver.team
}
]
case 'REMOVE_DRIVER':
return previousListOfDrivers.filter(driver =>
driver.name != action.driver.name
)
default:
return previousListOfDrivers;
}
}
Quando il Reducer drivers() riceve un Action con proprietà type uguale a ‘ADD_DRIVER’, usiamo l’operatore spread (…) per restituire un nuovo array, composto da tutti i valori presenti nel vecchio array a cui abbiamo concatenato il nuovo elemento da aggiungere. (È equivalente a scrivere previousListOfDrivers.concat(newDriver) dove newDriver è un oggetto avente come proprietà name e team).
A questo punto abbiamo due funzioni ognuna delle quali gestisce un pezzo dell’oggetto State della nostra applicazione. Dobbiamo creare un Reducer che gestisca l’intero oggetto State. Possiamo farlo combinando le due funzioni appena create. Possiamo usare la funzione combineReducers, definita nel modulo redux che possiamo scaricare con NPM. La funzione combineReducers riceve come argomento un oggetto in cui ogni proprietà ha come valore un riferimento a una funzione Reducer e restituisce una funzione Reducer che a sua volta ha come valore di ritorno un altro oggetto. Quest’ultimo avrà delle proprietà che hanno lo stesso nome di quelle dell’oggetto passato come argomento a combineReducers e per valore il risultato dell’esecuzione di quelle funzioni. L’oggetto restituito dal Reducer complessivo costituirà l’oggetto State dell’applicazione.
Vediamo un esempio per comprendere meglio il comportamento della funzione combineReducers.
import { combineReducers } from 'redux'
//... codice delle funzioni definite sopra
const rootReducer = combineReducers({
currentDriver: selectDriver,
drivers
});
Come abbiamo detto, combineReducers restituisce una funzione equivalente alla seguente.
export default function rootReducer(state = {}, action) {
return {
currentDriver: selectDriver(state.currentDriver, action),
drivers: drivers(state.drivers, action)
}
}
Notate che l’oggetto restituito dal Reducer "complessivo" ha come proprietà le stesse proprietà dell’oggetto passato come argomento alla funzione combineReducers che sono le stesse dell’oggetto State dell’applicazione. Nel caso della funzione rootReducer(), se l’oggetto state iniziale, passato come primo argomento è vuoto, le funzioni selectDriver e drivers riceveranno come primo argomento un oggetto che è undefined. Di conseguenza, useranno il valore di default ovvero {} e [] rispettivamente.
Terzo elemento: lo Store
L’ultimo elemento essenziale di Redux è lo Store che è l’oggetto che mantiene l’oggetto State dell’applicazione e fornisce alcuni metodi come getState() che restituisce l’oggetto State dell’applicazione e dispatch(action) che serve per lanciare un’Action. Store ha inoltre una funzione subscribe(listener) che serve per registrare una funzione che verrà invocata ogni volta che un’Action verrà lanciata da dispatch(action). All’interno della funzione che passiamo al metodo subscribe(), possiamo leggere il valore attuale dell’oggetto State invocando getState(). La funzione subscribe() è la funzione che useremo nell’applicazione per essere notificati che un’Action è stata lanciata e che è possibile che sia disponibile una versione aggiornata dell’oggetto State. Vedremo nel prossimo articolo come usare la funzione subscribe() per fare in modo che i componenti della nostra applicazione React vengano aggiornati in seguito alla modifica dell’oggetto State.
Per creare lo Store useremo la funzione createStore(rootReducer) fornita da Redux. La funzione createStore riceve altri argomenti che per ora trascuriamo. Il primo argomento da passare a createStore è il Reducer "complessivo" che restituisce l’oggetto State aggiornato. Nel nostro caso dovremo passare rootReducer.
import { createStore } from 'redux'
import rootReducer from './reducers'
const store = createStore(rootReducer);
Come spiegato dal creatore di Redux, Dan Abramov, la funzione createStore è essenzialmente la seguente. (Per maggiori dettagli potete leggere il codice di createStore nella repository GitHub di Redux)
const createStore = (rootReducer) => {
let state;
let listeners = [];
const dispatch = (action) => {
state = rootReducer(state, action);
listeners.forEach(listener => listener());
}
const getState = () => state;
const subscribe = (newListener) => {
listeners.push(newListener);
return () => {
listeners = listeners.filter(listener => listener !== newListener)
}
}
dispatch({});
return { dispatch, getState, subscribe };
}
Notate che la funzione subscribe() restituisce una funzione (che chiameremo unsubscribe()) che, se invocata, permette di rimuovere newListener dall’array delle funzioni che verranno invocate ogni volta che viene eseguita la funzione dispatch(). Viene inoltre invocata (subito prima del return) la funzione dispatch() passando un’Action che è un oggetto vuoto. In questo modo viene inizializzato l’oggetto State con il valore iniziale restituito dalla chiamata alla funzione rootReducer(undefined, {}).
Come funziona un’applicazione che usa Redux
Per concludere, riassumiamo con un’immagine come funziona un’applicazione che usa Redux. Usiamo l’esempio descritto sopra in cui abbiamo una lista di piloti e possiamo selezionare uno dei piloti per mostrare la sua scheda dettagliata. Se l’immagine risulta di difficile lettura, potete trovare la versione in alta risoluzione su Dropbox
Nel prossimo articolo vedremo come collegare Redux e React attraverso l’uso di react-redux.