Code, Learn

Quattro spigoli in Redux smussati dall’esperienza

21 Aprile 2020 - 7 minuti di lettura

Redux è una libreria per gestire lo stato di una applicazione. Spesso viene usata in coppia con React suddividendo i compiti per competenza: Redux gestisce lo stato dell’applicazione mentre React si occupa di mostrarlo.

Avendo utilizzato Redux in diversi progetti, Francesco Sacchi ha messo a punto alcuni trucchi per smussare degli spigoli che sono stati d’intralcio.

Premessa

In questo articolo si assume una conoscenza di base di Redux anche se per apprezzarlo appieno probabilmente bisogna averlo usato in almeno un progetto. Si rimanda a questa sezione della documentazione ufficiale per un elenco di risorse per imparare i concetti fondamentali di Redux.

Spigoli in Redux – 1. Switch case

I primi esempi di reducer, a partire da quelli che si trovano nella documentazione ufficiale, suggeriscono una implementazione basata su switch case:

function counter(state = 0, action) {
  switch (action.type) {
    case 'INCREMENT':
      return state + 1
    case 'DECREMENT':
      return state - 1
    default:
      return state
  }
}

A parte un personale fastidio rispetto a questo costrutto, penso che un reducer strutturato in questo modo sia poco scalabile. Sicuramente è un modo diretto ed efficace per illustrare il concetto in un caso semplice. Si rischia però che questa struttura venga mantenuta man mano che il reducer cresce e quindi gestisce più casi.
Il rischio è quello di arrivare ad una funzione composta da tante righe, aggravata dal fatto che lo switch-case si presta a pratiche a mio avviso fuorvianti come il fall-through dei case.

Per risolvere questi problemi uso una mappa che contiene delle funzioni che implementano i singoli casi da gestire. Siccome queste funzioni servono per rispondere ad una azione che arriva al reducer, le ho chiamate reazioni.

Facendo il parallelo con il reducer mostrato sopra, le reazioni sarebbero le seguenti:

const reactions = {
    INCREMENT: state => state + 1
    DECREMENT: state => state - 1
}

Per utilizzare queste reazioni uso una funzione di secondo ordine che genera il reducer:

const reducerFactory = (initialState, reactions) => {
  return (state = initialState, action) =>
    const reaction = reactions[action.type];
    if (reaction) {
      return reaction(state, action);
    }
    return state;
};

Questa funzione prende lo stato iniziale e la lista di reazioni e restituisce un reducer: una funzione che accetta uno stato e una azione e restituisce lo stato aggiornato.

In questo modo la funzione factory può essere testata una volta sola, lasciando da testare le singole reazioni che risultano però molto piccole, pure e facili da testare.

Con questa utility il reducer può essere istanziato come segue:

const counter = reducerFactory(0, reactions)

Spigoli in Redux – 2. Tipi di azioni

Le azioni sono di fatto dei semplici oggetti Javascript che per convenzione hanno una proprietà chiamata type, che definisce il tipo di azione.

Tradizionalmente le azioni vengono create tramite funzioni chiamate action creators. I tipi invece sono accumulati in variabili dette action definitions, in modo che possano essere usate poi anche nel reducer:

const INCREMENT = "INCREMENT";

const createIncrement = () => ({type: INCREMENT})

reactions = {
    [INCREMENT]: state => state + 1 // le parentesi quadre servono per usare la variabile INCREMENT e non la stringa "INCREMENT"
}

Ripetizione di stringhe e variabili a parte, un altro fastidio è relativo ad ogni volta che si vuole ricostruire il flusso di una azione. Tipicamente si parte dal componente React che usa l’action creator e sfruttando le comodità dell’IDE (ad esempio Webstorm) si risale alla sua definizione. Dobbiamo quindi cercare gli utilizzi della variabile del tipo e a questo punto arriviamo al reducer che è il punto che ci interessa.

Un approccio che ipotizzavo si è rivelato poi essere stato implementato nella libreria redux-actions: è possibile definire una funzione generica in grado di creare azioni in modo comodo da usare.

const creator = (type) => {
    const actionCreator = function() {return({type: type})}
    actionCreator.toString = () => type
    return actionCreator
}

L’azione viene quindi creata invocando questa utility passando come parametro la stringa che definisce il tipo dell’azione:

const increment = creator("INCREMENT")

Il trucco che permette di poter scrivere in un unico punto la stringa INCREMENT, senza necessità di variabile, è il sovrascrivere il toString della funzione restituita. Usando poi la funzione come proprietà nell’oggetto delle reazioni, essa sarà trasformata in stringa in quanto unico tipo ammesso come chiave. La conversione in stringa passerà dalla funzione sovrascritta, restituendo di fatto la stringa scelta come tipo:

const reactions = {
    [increment] : state => state + 1
}

In questo modo, oltre ad aver semplificato la creazione delle azioni, abbiamo anche rimosso uno step nella comprensione del flusso dei dati: partendo dal componente react troviamo l’action creator. Guardando gli utilizzi dell’action creator arriviamo direttamente al reducer.

Spigoli in Redux – 3. Immutabilità

Uno dei concetti fondamentali di Redux è che lo stato non può essere modificato.

Guardando il tutorial ufficiale viene proposta la seguente modalità per aggiornare lo stato senza modificarlo:

return Object.assign({}, state, {
  newProperty: action.newValue
})

Anche in questo caso, quando ci troviamo a dover implementare numerose reaction, replicare questa logica risulta oneroso e sconveniente.
Spesso viene quindi in aiuto una libreria di immutabilità per effettuare aggiornamenti che garantiscono l’immutabilità dello stato senza però sacrificare la leggibilità.
Una di queste librerie è immutable.js che fornisce le sue implementazioni immutabili di Map e List: lo store è quindi composto da soli oggetti di tipo immutable. Lo svantaggio di questo approccio è che ogni oggetto nello store deve essere convertito in immutable. Alla lunga questo mi ha creato confusione in quanto in vari punti non sapevo più se avessi a che fare con oggetti Javascript o oggetti immutable, portandomi all’utilizzo di metodi sbagliati, con enorme frustrazione.

La libreria che uso attualmente è immer: rispetto a immutable.js usa oggetti Javascript di base quindi non è necessario né imparare una nuova API, né convertire continuamente tra tipi.

L’integrazione con la struttura di reducer proposta è molto elegante: basta modificare la factory nel modo seguente:

const reducerFactory = (initialState, reactions) => {
  return (state = initialState, action) =>
    return produce(state, draft => {             // nuova riga
      const reaction = reactions[action.type];
      if (reaction) {
        return reaction(state, action);
      }
      // return state;                           // non più necessaria
    });
};

Ci si appoggia alla funzione produce la quale crea una copia dello stato, passato come primo parametro, e la fornisce come draft alla funzione passata come secondo parametro. Tutte le operazioni effettuate sul draft verranno quindi usate per produrre una nuova versione dello stato, senza modificare lo stato originario.

Oltre ad un impatto minimo sul codice, otteniamo due benefici interessanti a livello di reaction:

  • possiamo modificare direttamente quello che viene passato come stato, perché in realtà è un draft:
    invece di Object.assign({}, state, { newProperty: action.newValue }) possiamo semplicemente fare state.newProperty = action.newValue
  • non è più necessario fare return dello stato in quanto immer di default restituisce il draft, se null’altro è restituito

I reducer rimangono quindi ancora più semplici. Il lato negativo è che questo approccio va spiegato a chi lavora sulla codebase in quanto è fondamentalmente diverso dagli approcci standard.

Spigoli in Redux – 4. Test

I reducer sono una delle parti migliori che uno sviluppatore si possa trovare a dover testare: sono funzioni pure.
Dati i parametri di ingresso stato attuale e azione da applicare, generano un nuovo stato. Senza nessuna sorpresa ad un input relativamente semplice corrisponde in modo deterministico un output altrettanto semplice.

  it('should increment the state', () => {
    expect(counter(undefined, increment())).toEqual(1)
  })

Con la struttura presentata fino ad ora però, la gestione delle singole azioni è contenuta nelle reaction, che possono essere testate singolarmente.
Cosa rimane quindi da testare del reducer? A mio avviso la parte più interessante, ovvero gli scenari.
Tante volte il comportamento dell’applicazione è determinato non tanto da una azione, quanto da una sequenza di azioni.

Come utente ad esempio posso essere interessato al fatto che una azione incrementi il nostro contatore, ma difficilmente farò sempre e solo una azione. Uno scenario interessante e significativo è quindi che una sequenza di azioni porti ad un certo risultato.

Certo posso dedurre che se una singola azione funziona, allora ne funzioni anche una sequenza, ma se posso testarlo facilmente ho una maggiore confidenza che l’applicazione si comporti come dovrebbe.

Ecco come testo una di queste sequenze.

  it('should allow multiple increments', () => {
    const actions = [increment(), increment(), increment()]
    const nextState = actions.reduce(counter, undefined)
    expect(nextState).toEqual(3)
  })

Nell’esempio definiamo quindi una sequenza di tre incrementi e infine verifichiamo che lo stato finale corrisponda a 3.

La parte interessante è come facciamo a calcolare questo stato. Il succo è la funzione reduce.

Reduce serve per ridurre ad un unico elemento una lista, usando una funzione in grado di combinare insieme gli elementi della lista, a partire da un valore iniziale, undefined nel nostro caso.
Ma noi abbiamo già una funzione che è in grado, a partire da uno stato iniziale, di combinare varie azioni, una per volta, ottenendo un unico output: il nostro reducer! Che guarda caso è proprio quello che vogliamo testare.
Sottolineo ancora una volta che il primo parametro della funzione reduce è, non a caso, un reducer.
Riguardo allo stato iniziale ho esplicitato undefined perché in questo modo il reducer userà il suo stato iniziale di default, che è quello che poi accade nella applicazione reale.

In questo modo diventa semplice testare anche scenari complessi.

Conclusioni

Redux è una libreria semplice ma potente. In questo articolo ho presentato quattro pattern guidati dall’esperienza che mi aiutano quotidianamente ad essere più efficace con Redux.

Riferimenti

Articolo scritto da