Come visto negli esempi riportati finora, il flusso delle attività di Redux per gestire lo stato di un'applicazione è del tutto sincrono: il dispatching di un'azione genera immediatamente la catena di chiamate ai middleware ed ai reducer per effettuare la transizione di stato.
Come possiamo allora consentire la transizione di stato di un'applicazione tramite azioni asincrone, come ad esempio può essere la chiamata ad un server Web? Supponiamo ad esempio che nella nostra ToDo list vogliamo caricare dal server una lista di attività da fare. Dal momento che le chiamate HTTP sono asincrone, come facciamo ad integrare lo stato della nostra applicazione con i dati che arrivano dal server, rispettando il pattern architetturale di Redux?
L'approccio generalmente usato per integrare le chiamate asincrone nell'architettura di Redux consiste nello spezzare un'azione asincrona in almeno tre azioni sincrone:
- un'azione che informi che la richiesta asincrona è stata avviata;
- un'azione che informi che la richiesta asincrona si è conclusa con successo;
- un'azione che informi che la richiesta asincrona è fallita.
Ciascuna di queste azioni modificano lo stato dell'applicazione tenendola allineata con quello che sta avvenendo nel corso dell'esecuzione dell'attività asincrona. Sostanzialmente, se analizziamo la struttura generica di qualsiasi attività asincrona, avremo una fase di avvio dell'attività ed un gestore dell'esito dell'attività, come ad esempio una callback o una promise. Un'applicazione che usa Redux dovrebbe effettuare il dispatching dell'azione che avvia l'attività asincrona ed informa lo stato dell'applicazione che l'attività è stata avviata. Quando l'attività asincrona si conclude, il componente che gestisce l'esito dell'attività asincrona dovrebbe aggiornare opportunamente lo stato con una risposta positiva o negativa.
Da un punto di vista concettuale, questo dovrebbe essere l'approccio generale per integrare la gestione delle attività asincrone nell'ambito di Redux.
Una tentazione istintiva sarebbe quella di implementare il supporto delle azioni asincrone modificando opportunamente il relativo reducer, facendo in modo cioè che il reducer che intercetta l'azione avvii l'attività asincrona e ne gestisca l'esito. Questa implementazione violerebbe però il vincolo per cui un reducer deve essere una funzione pura. Infatti, il risultato di un'attività asincrona per sua natura si basa su un effetto collaterale.
Diversi possono essere gli approcci all'implementazione di azioni asincrone evitando di ricorrere alla modifica del reducer. Vediamo gli approcci più comunemente utilizzati.
Azioni asincrone e thunk
Un approccio per supportare le azioni asincrone consiste nello sfruttare il middleware thunk. Come abbiamo visto, thunk è in grado di intercettare azioni rappresentate tramite funzioni invece che con semplici oggetti. Possiamo quindi definire un'azione come funzione che avvia l'esecuzione di un'attività asincrona, ed affidare la sua esecuzione a thunk. A differenza del reducer, ad unmiddleware non è richiesto di essere una funzione pura, quindi thunk può eseguire funzioni che scatenano effetti collaterali senza alcun problema.
Vediamo dunque come procedere per implementare un'azione per il caricamento della lista delle attività dal server tramite una chiamata HTTP.
Come abbiamo detto prima, dobbiamo per prima cosa definire tre azioni sincrone che rappresentano i cambiamenti di stato durante l'esecuzione dell'attività asincrona. Definiamo ad esempio le seguenti costanti:
const GETLIST_REQUESTED = "GETLIST_REQUESTED";
const GETLIST_RECEIVED = "GETLIST_RECEIVED";
const GETLIST_FAILED = "GETLIST_FAILED";
La prima costante indica che la richiesta HTTP è stata effettuata, la seconda indica che è stata ricevuta una risposta dal server, mentre l'ultima indica che si è verificato un problema di comunicazione. Definiamo ora un action creator per thunk:
function getListAction() {
return function(dispatch) {
dispatch({
type: GETLIST_REQUESTED,
});
return fetch("api/todos").then(response => json(), error => dispatch({
type: GETLIST_FAILED,
payload: error
})).then(todoList => dispatch({
type: GETLIST_RECEIVED,
payload: todoList
}));
}
}
La prima cosa che possiamo notare in questo action creator è l'assenza del parametro getState
. A differenza delle azioni sincrone, infatti, la nostra azione non accede direttamente allo stato dell'applicazione. Essa si limita semplicemente ad inoltrare altre azioni in base all'evolversi della richiesta asincrona, e pertanto le è sufficiente il metodo dispatch()
.
Seguendo il codice notiamo che la prima azione effettuata è, infatti, il dispatch dell'azione GETLIST_REQUESTED
, che notifica allo stato dell'applicazione il fatto che l'azione asincrona è stata avviata.
Segue la chiamata vera e propria al server tramite fetch()
e la gestione della relativa risposta asincrona. Quello che succede alla ricezione della risposta dipende dall'esito. Se si è verificato un problema, e quindi la promise di fetch()
è stata rigettata, viene effettuato il dispatch dell'azione GETLIST_FAILED
, con payload costituito dall'errore generato. Se la promise viene risolta, quindi otteniamo la lista di attività come risposta, viene deserializzato il JSON ottenuto ed effettuato il dispatch dell'azione GETLIST_RECEIVED
.
Da notare che, per semplicità, abbiamo supposto che quando la promise di fetch()
viene risolta otteniamo una risposta positiva dal server, cioè otteniamo uno stato HTTP 200 OK. In situazioni reali dobbiamo verificare che lo stato HTTP restituito dal server sia quello giusto prima di deserializzare la risposta.
La funzione che verrà elaborata da thunk restituisce quindi la promise generata da fetch()
. In realtà questo non è richiesto da thunk, ma può tornare utile nel caso volessimo eseguire del codice alla ricezione di una risposta da parte del server.
Una volta definita la trasformazione di un'azione asincrona in tre azioni sincrone, dobbiamo gestire il loro impatto sulle transizioni di stato. Per la nostra ToDo list, l'azione rilevante che genera un cambiamento effettivo dello stato è l'azione GETLIST_RECEIVED
. In corrispondenza di questa azione, la lista delle attività deve essere aggiornata con quella ricevuta dal server. Riprendiamo quindi il reducer per la gestione dello stato della ToDo list ed integriamolo come mostrato di seguito:
function todo(state = [], action) {
switch (action.type) {
case ADD:
//...
break;
case REMOVE:
//...
break;
case GETLIST_RECEIVED:
state = [...action.payload];
break;
}
return state
}
Come possiamo vedere, in corrispondenza dell'azione GETLIST_RECEIVED
sostituiamo la lista delle attività presenti nello stato dell'applicazione con quelle ricevute dal server. Naturalmente, la strategia di gestione dei dati provenienti dal server può essere diversa. Ad esempio, potevamo scegiere di mettere insieme i dati provenienti dal server con quelli presenti sullo stato dell'applicazione, ma questo dipende da come vogliamo modellare il comportamento della nostra applicazione.
Le altre azioni GETLIST_REQUESTED
e GETLIST_FAILED
non avrebbero un impatto diretto sui dati relativi alla ToDo list presenti nello stato dell'applicazione. Esse potrebbero essere ignorate del tutto dal punto di vista funzionale, ma è buona norma gestirle opportunamente, ad esempio per dare un feedback all'utente dello stato di avanzamento della richiesta asincrona.
Potremmo ad esempio introdurre una nuova porzione dello stato dedicata a questo scopo, in modo tale che l'interfaccia utente possa informare l'utente sul fatto che la richiesta è stata avviata (GETLIST_REQUESTED
) o che si è verificata una situazione d'errore (GETLIST_FAILED
).
Il seguente è un esempio di possibile reducer per la gestione di questi stati:
function asyncStaus(state = {}, action) {
switch (action.type) {
case GETLIST_REQUESTED:
state = Object.assign({}, state, {
status: "waiting"
});
break;
case GETLIST_FAILED:
state = Object.assign({}, state, {
status: "failed",
error: action.payload
});
break;
}
return state
}
Le modifiche alla porzione di stato coinvolto dal reducer possono essere intercettate dall'interfaccia grafica per mostrare, ad esempio, uno spinner in corrispondenza dell'azione GETLIST_REQUESTED
ed un messaggio d'errore in corrispondenza dell'azione GETLIST_FAILED
.