Migliori riduttori con Immer

Pubblicato: 2022-03-10
Riassunto veloce ↬ In questo articolo impareremo come usare Immer per scrivere riduttori. Quando lavoriamo con React, manteniamo molto stato. Per apportare aggiornamenti al nostro stato, dobbiamo scrivere molti riduttori. La scrittura manuale dei riduttori provoca un codice gonfio in cui dobbiamo toccare quasi ogni parte del nostro stato. Questo è noioso e soggetto a errori. In questo articolo, vedremo come Immer apporta più semplicità al processo di scrittura dei riduttori di stato.

Come sviluppatore React, dovresti già avere familiarità con il principio che lo stato non dovrebbe essere mutato direttamente. Ti starai chiedendo cosa significhi (la maggior parte di noi aveva quella confusione quando abbiamo iniziato).

Questo tutorial renderà giustizia a questo: capirai cos'è lo stato immutabile e la necessità di esso. Imparerai anche come utilizzare Immer per lavorare con lo stato immutabile e i vantaggi del suo utilizzo. Puoi trovare il codice in questo articolo in questo repository Github.

Immutabilità in JavaScript e perché è importante

Immer.js è una piccola libreria JavaScript è stata scritta da Michel Weststrate la cui missione dichiarata è consentire di "lavorare con lo stato immutabile in un modo più conveniente".

Ma prima di immergerci in Immer, facciamo un rapido aggiornamento sull'immutabilità in JavaScript e sul perché è importante in un'applicazione React.

L'ultimo standard ECMAScript (aka JavaScript) definisce nove tipi di dati integrati. Di questi nove tipi, ce ne sono sei che vengono definiti valori/tipi primitive . Queste sei primitive sono undefined , number , string , boolean , bigint e symbol . Un semplice controllo con l'operatore typeof di JavaScript rivelerà i tipi di questi tipi di dati.

 console.log(typeof 5) // number console.log(typeof 'name') // string console.log(typeof (1 < 2)) // boolean console.log(typeof undefined) // undefined console.log(typeof Symbol('js')) // symbol console.log(typeof BigInt(900719925474)) // bigint

Una primitive è un valore che non è un oggetto e non ha metodi. La cosa più importante per la nostra discussione attuale è il fatto che il valore di una primitiva non può essere cambiato una volta che è stata creata. Pertanto, si dice che le primitive siano immutable .

I restanti tre tipi sono null , object e function . Possiamo anche controllare i loro tipi usando l'operatore typeof .

 console.log(typeof null) // object console.log(typeof [0, 1]) // object console.log(typeof {name: 'name'}) // object const f = () => ({}) console.log(typeof f) // function

Questi tipi sono mutable . Ciò significa che i loro valori possono essere modificati in qualsiasi momento dopo la loro creazione.

Ti starai chiedendo perché ho l'array [0, 1] lassù. Bene, in JavaScriptland, un array è semplicemente un tipo speciale di oggetto. Nel caso ti stia anche chiedendo null e come sia diverso da undefined . undefined significa semplicemente che non abbiamo impostato un valore per una variabile mentre null è un caso speciale per gli oggetti. Se sai che qualcosa dovrebbe essere un oggetto ma l'oggetto non è presente, restituisci semplicemente null .

Per illustrare con un semplice esempio, prova a eseguire il codice seguente nella console del browser.

 console.log('aeiou'.match(/[x]/gi)) // null console.log('xyzabc'.match(/[x]/gi)) // [ 'x' ]

String.prototype.match dovrebbe restituire un array, che è un tipo di object . Quando non riesce a trovare un tale oggetto, restituisce null . Anche il ritorno undefined non avrebbe senso qui.

Basta con quello. Torniamo a parlare di immutabilità.

Altro dopo il salto! Continua a leggere sotto ↓

Secondo i documenti MDN:

"Tutti i tipi tranne gli oggetti definiscono valori immutabili (ovvero valori che non possono essere modificati)."

Questa istruzione include funzioni perché sono un tipo speciale di oggetto JavaScript. Vedi qui la definizione della funzione.

Diamo una rapida occhiata a cosa significano in pratica i tipi di dati mutabili e immutabili. Prova a eseguire il codice seguente nella console del browser.

 let a = 5; let b = a console.log(`a: ${a}; b: ${b}`) // a: 5; b: 5 b = 7 console.log(`a: ${a}; b: ${b}`) // a: 5; b: 7

I nostri risultati mostrano che anche se b è “derivato” da a , la modifica del valore di b non influisce sul valore di a . Ciò deriva dal fatto che quando il motore JavaScript esegue l'istruzione b = a , crea una nuova posizione di memoria separata, inserisce 5 e punta b in quella posizione.

E gli oggetti? Considera il codice seguente.

 let c = { name: 'some name'} let d = c; console.log(`c: ${JSON.stringify(c)}; d: ${JSON.stringify(d)}`) // {"name":"some name"}; d: {"name":"some name"} d.name = 'new name' console.log(`c: ${JSON.stringify(c)}; d: ${JSON.stringify(d)}`) // {"name":"new name"}; d: {"name":"new name"}

Possiamo vedere che cambiando la proprietà name tramite la variabile d cambia anche in c . Ciò deriva dal fatto che quando il motore JavaScript esegue l'istruzione, c = { name: 'some name ' } , il motore JavaScript crea uno spazio in memoria, inserisce l'oggetto all'interno e punta c su di esso. Quindi, quando esegue l'istruzione d = c , il motore JavaScript punta semplicemente d nella stessa posizione. Non crea una nuova posizione di memoria. Pertanto, qualsiasi modifica agli elementi in d è implicitamente un'operazione sugli elementi in c . Senza molti sforzi, possiamo capire perché questo è un problema in arrivo.

Immagina di sviluppare un'applicazione React e da qualche parte vuoi mostrare il nome dell'utente come some name leggendo dalla variabile c . Ma da qualche altra parte avevi introdotto un bug nel tuo codice manipolando l'oggetto d . Ciò comporterebbe la visualizzazione del nome dell'utente come new name . Se c e d fossero primitivi non avremmo questo problema. Ma le primitive sono troppo semplici per il tipo di stato che una tipica applicazione React deve mantenere.

Si tratta dei motivi principali per cui è importante mantenere uno stato immutabile nell'applicazione. Ti incoraggio a controllare alcune altre considerazioni leggendo questa breve sezione del README di Immutable.js: il caso dell'immutabilità.

Avendo capito perché abbiamo bisogno dell'immutabilità in un'applicazione React, diamo ora un'occhiata a come Immer affronta il problema con la sua funzione di produce .

Funzione di produce di Immer

L'API principale di Immer è molto piccola e la funzione principale con cui lavorerai è la funzione di produce . produce prende semplicemente uno stato iniziale e un callback che definisce come lo stato dovrebbe essere mutato. La stessa richiamata riceve una bozza (identica, ma comunque una copia) della copia dello stato a cui effettua tutti gli aggiornamenti previsti. Infine, produce un nuovo stato immutabile con tutte le modifiche applicate.

Il modello generale per questo tipo di aggiornamento dello stato è:

 // produce signature produce(state, callback) => nextState

Vediamo come funziona in pratica.

 import produce from 'immer' const initState = { pets: ['dog', 'cat'], packages: [ { name: 'react', installed: true }, { name: 'redux', installed: true }, ], } // to add a new package const newPackage = { name: 'immer', installed: false } const nextState = produce(initState, draft => { draft.packages.push(newPackage) })

Nel codice sopra, passiamo semplicemente lo stato iniziale e un callback che specifica come vogliamo che avvengano le mutazioni. E 'così semplice. Non abbiamo bisogno di toccare nessun'altra parte dello stato. Lascia initState intatto e condivide strutturalmente quelle parti dello stato che non abbiamo toccato tra l'inizio e i nuovi stati. Una di queste parti nel nostro stato è l'array di pets . produce d nextState è un albero di stato immutabile che contiene le modifiche che abbiamo apportato e le parti che non abbiamo modificato.

Forti di questa semplice ma utile conoscenza, diamo un'occhiata a come i produce possono aiutarci a semplificare i nostri riduttori React.

Riduttori di scrittura con Immer

Supponiamo di avere l'oggetto stato definito di seguito

 const initState = { pets: ['dog', 'cat'], packages: [ { name: 'react', installed: true }, { name: 'redux', installed: true }, ], };

E volevamo aggiungere un nuovo oggetto e, in un passaggio successivo, impostare la sua chiave installed su true

 const newPackage = { name: 'immer', installed: false };

Se dovessimo farlo nel solito modo con la sintassi dell'oggetto JavaScript e della diffusione dell'array, il nostro riduttore di stato potrebbe apparire come di seguito.

 const updateReducer = (state = initState, action) => { switch (action.type) { case 'ADD_PACKAGE': return { ...state, packages: [...state.packages, action.package], }; case 'UPDATE_INSTALLED': return { ...state, packages: state.packages.map(pack => pack.name === action.name ? { ...pack, installed: action.installed } : pack ), }; default: return state; } };

Possiamo vedere che questo è inutilmente prolisso e soggetto a errori per questo oggetto di stato relativamente semplice. Dobbiamo anche toccare ogni parte dello stato, il che non è necessario. Vediamo come possiamo semplificarlo con Immer.

 const updateReducerWithProduce = (state = initState, action) => produce(state, draft => { switch (action.type) { case 'ADD_PACKAGE': draft.packages.push(action.package); break; case 'UPDATE_INSTALLED': { const package = draft.packages.filter(p => p.name === action.name)[0]; if (package) package.installed = action.installed; break; } default: break; } });
E con poche righe di codice, abbiamo notevolmente semplificato il nostro riduttore. Inoltre, se cadiamo nel caso predefinito, Immer restituisce semplicemente lo stato di bozza senza che dobbiamo fare nulla. Nota come c'è meno codice standard e l'eliminazione della diffusione dello stato. Con Immer, ci occupiamo solo della parte dello stato che vogliamo aggiornare. Se non riusciamo a trovare un tale elemento, come nell'azione `UPDATE_INSTALLED`, andiamo semplicemente avanti senza toccare nient'altro. La funzione "produrre" si presta anche al curry. Passare un callback come primo argomento per "produrre" è inteso per essere utilizzato per il curry. La firma del "prodotto" al curry è
 //curried produce signature produce(callback) => (state) => nextState
Vediamo come possiamo aggiornare il nostro stato precedente con un prodotto al curry. I nostri prodotti al curry sarebbero così:
 const curriedProduce = produce((draft, action) => { switch (action.type) { case 'ADD_PACKAGE': draft.packages.push(action.package); break; case 'SET_INSTALLED': { const package = draft.packages.filter(p => p.name === action.name)[0]; if (package) package.installed = action.installed; break; } default: break; } });

La funzione Curried Produce accetta una funzione come primo argomento e restituisce un Curried Produce che solo ora richiede uno stato da cui produrre lo stato successivo. Il primo argomento della funzione è lo stato bozza (che sarà derivato dallo stato da passare quando si chiama questo prodotto al curry). Quindi segue ogni numero di argomenti che desideriamo passare alla funzione.

Tutto ciò che dobbiamo fare ora per utilizzare questa funzione è passare nello stato da cui vogliamo produrre lo stato successivo e l'oggetto azione in questo modo.

 // add a new package to the starting state const nextState = curriedProduce(initState, { type: 'ADD_PACKAGE', package: newPackage, }); // update an item in the recently produced state const nextState2 = curriedProduce(nextState, { type: 'SET_INSTALLED', name: 'immer', installed: true, });

Si noti che in un'applicazione React quando si utilizza l'hook useReducer , non è necessario passare lo stato in modo esplicito come ho fatto sopra perché se ne occupa.

Ti starai chiedendo, Immer starebbe ottenendo un hook , come tutto in React in questi giorni? Bene, sei in compagnia di buone notizie. Immer ha due hook per lavorare con state: gli useImmer e useImmerReducer . Vediamo come funzionano.

Utilizzo useImmer e useImmerReducer

La migliore descrizione useImmer viene dallo stesso README use-immer.

useImmer(initialState) è molto simile a useState . La funzione restituisce una tupla, il primo valore della tupla è lo stato corrente, il secondo è la funzione di aggiornamento, che accetta una funzione di produttore immer, in cui la draft può essere mutata liberamente, fino a quando il produttore non finisce e verranno apportate le modifiche immutabile e diventa lo stato successivo.

Per utilizzare questi ganci, è necessario installarli separatamente, oltre alla libreria principale Immer.

 yarn add immer use-immer

In termini di codice, l'hook useImmer appare come di seguito

 import React from "react"; import { useImmer } from "use-immer"; const initState = {} const [ data, updateData ] = useImmer(initState)

Ed è così semplice. Si potrebbe dire che è useState di React ma con un po' di steroidi. Utilizzare la funzione di aggiornamento è molto semplice. Riceve lo stato di bozza e puoi modificarlo quanto vuoi come di seguito.

 // make changes to data updateData(draft => { // modify the draft as much as you want. })

Il creatore di Immer ha fornito un esempio di codici e scatole con cui puoi giocare per vedere come funziona.

useImmerReducer è altrettanto semplice da usare se hai usato l'hook useReducer di React. Ha una firma simile. Vediamo come appare in termini di codice.

 import React from "react"; import { useImmerReducer } from "use-immer"; const initState = {} const reducer = (draft, action) => { switch(action.type) { default: break; } } const [data, dataDispatch] = useImmerReducer(reducer, initState);

Possiamo vedere che il riduttore riceve una draft stato che possiamo modificare quanto vogliamo. C'è anche un esempio di codici e box qui con cui puoi sperimentare.

Ed è così semplice usare i ganci Immer. Ma nel caso ti stia ancora chiedendo perché dovresti usare Immer nel tuo progetto, ecco un riepilogo di alcuni dei motivi più importanti che ho trovato per usare Immer.

Perché dovresti usare Immer

Se hai scritto la logica di gestione dello stato per un certo periodo di tempo, apprezzerai rapidamente la semplicità offerta da Immer. Ma questo non è l'unico vantaggio offerto da Immer.

Quando usi Immer, finisci per scrivere meno codice standard, come abbiamo visto con riduttori relativamente semplici. Questo rende anche gli aggiornamenti profondi relativamente facili.

Con librerie come Immutable.js, devi imparare una nuova API per sfruttare i vantaggi dell'immutabilità. Ma con Immer si ottiene lo stesso risultato con normali Objects JavaScript, Arrays , Sets e Maps . Non c'è niente di nuovo da imparare.

Immer fornisce anche la condivisione strutturale per impostazione predefinita. Ciò significa semplicemente che quando si apportano modifiche a un oggetto di stato, Immer condivide automaticamente le parti invariate dello stato tra il nuovo stato e lo stato precedente.

Con Immer, ottieni anche il congelamento automatico degli oggetti, il che significa che non puoi apportare modifiche allo stato produced . Ad esempio, quando ho iniziato a utilizzare Immer, ho provato ad applicare il metodo di sort su una matrice di oggetti restituiti dalla funzione di produzione di Immer. Ha generato un errore dicendomi che non posso apportare modifiche all'array. Ho dovuto applicare il metodo della fetta dell'array prima di applicare sort . Ancora una volta, il nextState prodotto è un albero di stato immutabile.

Immer è anche fortemente tipizzato e molto piccolo a soli 3 KB quando compresso con gzip.

Conclusione

Quando si tratta di gestire gli aggiornamenti di stato, l'utilizzo di Immer è un gioco da ragazzi per me. È una libreria molto leggera che ti consente di continuare a utilizzare tutte le cose che hai imparato su JavaScript senza cercare di imparare qualcosa di completamente nuovo. Ti incoraggio a installarlo nel tuo progetto e iniziare a usarlo subito. Puoi aggiungere utilizzarlo in progetti esistenti e aggiornare in modo incrementale i tuoi riduttori.

Ti incoraggio anche a leggere il post introduttivo sul blog di Immer di Michael Weststrate. La parte che trovo particolarmente interessante è "Come funziona Immer?" sezione che spiega come Immer sfrutta le funzionalità del linguaggio come proxy e concetti come copy-on-write.

Ti incoraggio anche a dare un'occhiata a questo post del blog: Immutability in JavaScript: A Contratian View in cui l'autore, Steven de Salas, presenta i suoi pensieri sui meriti di perseguire l'immutabilità.

Spero che con le cose che hai imparato in questo post tu possa iniziare subito a usare Immer.

Risorse correlate

  1. use-immer , GitHub
  2. Immer, GitHub
  3. function , documenti web MDN, Mozilla
  4. proxy , documenti web MDN, Mozilla
  5. Oggetto (informatica), Wikipedia
  6. "Immutabilità in JS", Orji Chidi Matthew, GitHub
  7. "Tipi e valori di dati ECMAScript", Ecma International
  8. Raccolte immutabili per JavaScript, Immutable.js , GitHub
  9. "Il caso dell'immutabilità", Immutable.js, GitHub