Abbiamo visto come Redux lascia sostanzialmente ampia libertà nello scrivere il codice che dovrà gestire lo stato di un'applicazione. Siamo liberi di strutturare lo stato dell'applicazione come vogliamo, possiamo scrivere azioni con la struttura che ci è più conveniente, possiamo definire un reducer come una normale funzione JavaScript. In fondo, i vincoli da rispettare sono veramente pochi: dobbiamo utilizzare lo store per la custodia dello stato dell'applicazione e i reducer devono essere funzioni pure che restituiscono lo stato successivo a quello di ingresso in base all'azione passata. Il resto è rappresentato da convenzioni che non siamo obbligati a rispettare, anche se in linea di massima è conveniente per garantire una certa comprensibilità ed interoperabilità del codice.
Tuttavia, il pattern proposto da Redux ci obbliga ad incanalare il flusso delle informazioni in un percorso obbligato:
- dispatching dell'azione verso lo store;
- applicazione dell'azione sullo stato tramite i reducer
- intercettazione delle modifiche allo stato dell'applicazione.
Se volessimo in qualche modo interferire in questo flusso dovremmo intervenire in uno di questi punti. Ad esempio, se a scopo di debug volessimo registrare sulla console le transizioni di stato della nostra applicazione, dovremmo modificare opportunamente il codice. Questo intervento può essere fatto in diversi modi in base al livello di pulizia e manutenibilità del codice che vogliamo ottenere: possiamo registrare l'azione applicata ed il nuovo stato dopo il dispatch di ciascuna azione, ma questo comporterebbe l'individuazione di tutti i punti in cui viene effettuato il dispatch di un'azione e l'onere di dover scrivere il codice di scrittura sulla console ad ogni nuova azione che verrà introdotta; potremmo sostituire la funzione di dispatch di Redux con una nostra versione che si occupi di scrivere le transizioni di stato sulla console (monkey patch), ma oltre ad essere una tecnica generalmente da evitare, ci costringerebbe a complicare la funzione di dispatching con attività estranee.
Per fortuna Redux ci mette a disposizione un approccio elegante e modulare alla soluzione di questo problema: l'utilizzo di un middleware.
Nell'ambito di Redux, un middleware è una funzione eseguita tra il dispatching di un'azione e la sua elaborazione da parte del reducer. Essa rappresenta a tutti gli effetti un modo per modificare il comportamento standard del flusso di elaborazione dello stato da parte di Redux. Il seguente schema rappresenta le interazioni tra i vari componenti del pattern proposto da Redux, arricchito dalla presenza del middleware:
Come possiamo vedere dalla figura, tra il dispatching di un'azione ed il reducer che applicherà la transizione di stato, possiamo inserire più middleware che verranno eseguiti in sequenza in base ad un ordine definito in fase di inizializzazione dello store, come avremo modo di vedere più avanti.
Tornando al nostro esempio di logging delle transizioni di stato, possiamo risolvere efficacemente il problema scrivendo un middleware specifico.
Su Redux, un middleware è una funzione con la seguente struttura:
var middleware = function (store) {
return function (next) {
return function (action) {
//istruzioni specifiche
return next(action);
};
};
};
Una forma più compatta è quella che sfrutta la sintassi delle arrow function:
const logger = store => next => action => {
//istruzioni specifiche
return next(action);
}
A prescindere dalla forma sintattica preferita, non possiamo negare che la struttura di un middlware per Redux possa intimorire. Essa applica un concetto di programmazione funzionale noto come applicazione parziale di una funzione o currying. Con questa tecnica possiamo creare funzioni specializzate a partire da funzioni più generiche. Senza scendere nei dettagli implementativi, combinando la composizione e l'applicazione parziale di funzioni possiamo mettere in sequenza l'esecuzione di più funzioni con un approccio molto potente.
In sostanza, un middleware è una funzione che restituisce una funzione, che a sua volta restituisce una funzione. La prima funzione accetta store come parametro, la seconda prevede il parametro next, mentre la terza ottiene il parametro action. I parametri store e action rappresentano rispettivamente lo store corrente e l'azione di cui stiamo effettuato il dispatching. Il parametro next rappresenta il middleware successivo nella pipeline prima di arrivare al reducer. In pratica, all'interno di un middleware possiamo sfruttare lo store e l'action correnti per effettuare le nostre elaborazioni specifiche e passare poi la palla all'eventuale middleware successivo.
Per mostrare un caso concreto, vediamo come può essere implementato un semplice middleware per scrivere sulla console l'azione intercettata e il nuovo stato dell'applicazione:
const logger = store => next => action => {
console.log('Azione corrente: ', action)
let result = next(action)
console.log('Stato successivo: ', store.getState())
return result
}
Notiamo come in questo caso specifico attendiamo l'esecuzione degli eventuali middleware successivi prima di scrivere sulla console il nuovo stato. Se non facessimo così scriveremmo lo stato corrente, ma chiaramente non è quello che vogliamo ottenere.
Una volta definito il nostro middleware dobbiamo renderlo disponibile a Redux in modo che possa prenderlo in considerazione nella gestione dello stato. Questo viene effettuato in fase di inizializzazione dello store come mostrato dal seguente codice:
import { createStore, applyMiddleware } from 'redux'
const initialState = { todoList: []};
let store = createStore(todo, initialState, applyMiddleware(logger));
Come possiamo vedere, abbiamo creato lo store di Redux passando oltre al reducer todo()
ed allo stato iniziale initialState
anche il risultato dell'applicazione della funzione di Redux applyMiddleware()
sul nostro middleware logger
.
Questo farà sì che ogni transizione di stato verrà tracciata sulla console del nostro browser, come mostra la seguente immagine:
Nel caso avessimo più middleware da eseguire prima dell'applicazione del reducer, possiamo passarli come parametri della funzione applyMiddleware()
come mostrato dal seguente esempio:
let middleware = applyMiddleware(middl1, middl2, middl3);
È importante notare come l'ordine di esecuzione dei middleware seguirà l'ordine con cui le funzioni vengono passate come parametro ad applyMiddleware()
. In determinate situazioni l'ordine di esecuzione dei middleware può essere rilevante, dal momento che potenzialmente un middleware può effettuare qualsiasi tipo di operazione, come ad esempio modificare l'azione originale.