Estrarre la Logica dello State in un Reducer
I componenti con molti aggiornamenti di state distribuiti su molti event handler può diventare eccessivo. In questi casi, è possibile consolidare tutti gli aggiornamenti della logica dello state fuori dal componente in una singola funzione, chiamata reducer.
Imparerai
- Cos’è una funzione di reducer
- Come rifattorizzare
useState
inuseReducer
- Quando utilizzare un reducer
- Come scriverne una bene
Consolidare la logica dello state con una reducer
Come i tuoi componenti crescono in complessità, può diventare difficile vedere a primo d’occhio tutti i modi differenti nel quale uno state di un componente viene aggiornato. Per esempio, il componente TaskApp
contiene sotto un array di tasks
in state e usa tre differenti event handler per aggiungere e modificare tasks:
import { useState } from 'react'; import AddTask from './AddTask.js'; import TaskList from './TaskList.js'; export default function TaskApp() { const [tasks, setTasks] = useState(initialTasks); function handleAddTask(text) { setTasks([ ...tasks, { id: nextId++, text: text, done: false, }, ]); } function handleChangeTask(task) { setTasks( tasks.map((t) => { if (t.id === task.id) { return task; } else { return t; } }) ); } function handleDeleteTask(taskId) { setTasks(tasks.filter((t) => t.id !== taskId)); } return ( <> <h1>Prague itinerary</h1> <AddTask onAddTask={handleAddTask} /> <TaskList tasks={tasks} onChangeTask={handleChangeTask} onDeleteTask={handleDeleteTask} /> </> ); } let nextId = 3; const initialTasks = [ {id: 0, text: 'Visit Kafka Museum', done: true}, {id: 1, text: 'Watch a puppet show', done: false}, {id: 2, text: 'Lennon Wall pic', done: false}, ];
Ciascuno dei suoi event handler chiama setTasks
in ordine di aggiornare lo state. Al crescere del componente, cresce anche la quantità di logica dello state cosparse da esso. Per ridurre la complessità e mantenere la logica in un posto easy-to-access, puoi spostare quella logica di state dentro ad una singola funzione al di fuori del componente, chiamata “reducer”.
Le funzioni reducer sono un modo differente di gestire lo state. Puoi migrare da useState
a useReducer
in tre passaggi:
- Sposta dal setting state alle azioni di dispatching.
- Scrivi una funzione di reducer.
- Usa la reducer dal tuo componente.
Passaggio 1: Sposta dal setting state alle azioni di dispatching
I tuoi event handler attualmente specificano cosa fare dal setting state:
function handleAddTask(text) {
setTasks([
...tasks,
{
id: nextId++,
text: text,
done: false,
},
]);
}
function handleChangeTask(task) {
setTasks(
tasks.map((t) => {
if (t.id === task.id) {
return task;
} else {
return t;
}
})
);
}
function handleDeleteTask(taskId) {
setTasks(tasks.filter((t) => t.id !== taskId));
}
Rimuovi tutta la logica del setting state. Cosa devi lasciare con i tre event handler:
handleAddTask(text)
viene chiamato quando l’utente preme “Add”.handleChangeTask(task)
viene chiamato quando l’utente aziona un task o preme “Save”.handleDeleteTask(taskId)
viene chiamato quando l’utente preme “Delete”.
Gestire lo state con i reducer è leggermente diverso dall’utilizzare direttamente un setting state. Invece di dire a React “cosa fare” utilizzando setting state, specifichi “cosa lo user ha appena fatto” utilizzando delle azioni di dispatching che provengono dai tuoi event handler. (Lo state che aggiorna la logica vive da un altra parte!) Quindi invece di “impostare tasks
” tramite un event handler, stai utiilizzando un’azione di dispatching come “aggiungere/modificare/cancellare un task”. Questo descrive molto di più l’intenzione dell’utente.
function handleAddTask(text) {
dispatch({
type: 'added',
id: nextId++,
text: text,
});
}
function handleChangeTask(task) {
dispatch({
type: 'changed',
task: task,
});
}
function handleDeleteTask(taskId) {
dispatch({
type: 'deleted',
id: taskId,
});
}
L’oggetto che passi al dispatch
è chiamata “azione”:
function handleDeleteTask(taskId) {
dispatch(
// "action" object:
{
type: 'deleted',
id: taskId,
}
);
}
È un normale oggetto JavaScript. Decidi tu cosa metterci dentro, ma generalmente dovrebbe contenere la minima informazione riguardo cosa è successo. (Aggiungerai la funzione di dispatch
in un altro passaggio.)
Step 2: Scrivi una funzione reducer
Una funzione reducer è dove metterai la tua logica dello state. Prende due argomenti, lo state corrente e l’oggetto action e ritorna il nuovo state:
function yourReducer(state, action) {
// ritorna il nuovo state per React da utilizzare
}
React metterà quello che viene ritornato dalla funzione reducer nello state.
Per spostare la logica set dello state dai tuoi event handler in una funzione reducer in questo esempio, dovrai:
- Dichiarare lo state corrente (
tasks
) come primo argomento. - Dichiarare l’oggetto
action
come secondo argomento. - Ritornare il next state dal reducer (il quale verrà collocato nello state da React).
Qui c’è tutta la logica di impostazione dello state migrata in una funzione reducer:
function tasksReducer(tasks, action) {
if (action.type === 'added') {
return [
...tasks,
{
id: action.id,
text: action.text,
done: false,
},
];
} else if (action.type === 'changed') {
return tasks.map((t) => {
if (t.id === action.task.id) {
return action.task;
} else {
return t;
}
});
} else if (action.type === 'deleted') {
return tasks.filter((t) => t.id !== action.id);
} else {
throw Error('Unknown action: ' + action.type);
}
}
Poiché la funzione reducer prende lo state (tasks
) come argomento, puoi dichiararla all’esterno del tuo componente. Ciò riduce il livello di indentazione e può rendere il tuo codice più leggibile.
Approfondimento
Anche se i reducer possono “ridurre” la quantità di codice all’interno del tuo componente, prendono in realtà il nome dall’operazione reduce()
che puoi eseguire su array.
L’operazione reduce()
ti permette di prendere un array e “accumulare” un singolo valore da molti:
const arr = [1, 2, 3, 4, 5];
const sum = arr.reduce(
(result, number) => result + number
); // 1 + 2 + 3 + 4 + 5
La funzione che passi al reduce
è conosciuta come un “reducer”. Essa prende il risultato fino a quel momento e l’elemento corrente, per poi restituire il prossimo risultato. I reducer di React sono un esempio della stessa idea: essi prendono lo state fino a quel momento e l’azione, restituendo poi lo state successivo. In questo modo, essi accumulano le azioni nel tempo all’interno dello state.
Puoi persino utilizzare il metodo reduce()
con uno initialState
e un array di azioni
per calcolare lo stato finale passando la tua funzione reducer ad esso:
import tasksReducer from './tasksReducer.js'; let initialState = []; let actions = [ {type: 'added', id: 1, text: 'Visit Kafka Museum'}, {type: 'added', id: 2, text: 'Watch a puppet show'}, {type: 'deleted', id: 1}, {type: 'added', id: 3, text: 'Lennon Wall pic'}, ]; let finalState = actions.reduce(tasksReducer, initialState); const output = document.getElementById('output'); output.textContent = JSON.stringify(finalState, null, 2);
Probabilmente non dovrai farlo da solo, ma questo è simile a ciò che fa React!
Step 3: Usa il reducer dal tuo componente
Infine, devi collegare il tasksReducer
al tuo componente. Importa l’Hook useReducer
da React:
import { useReducer } from 'react';
Poi puoi sostituire useState
:
const [tasks, setTasks] = useState(initialTasks);
Con useReducer
così:
const [tasks, dispatch] = useReducer(tasksReducer, initialTasks);
L’Hook useReducer
è simile allo useState
-devi passargli uno stato iniziale e lui ti restituisce un valore dello stato e un modo per impostare lo stato (in questo caso, la funzione dispatch). Ma è un po’ diverso.
L’Hook useReducer
prende due argomenti:
- Una funzione reducer
- Uno state iniziale
E restituisce:
- Un valore dello state
- Una funzione dispatch (per “inviare” azioni dell’utente al reducer)
Ora è completamente collegato! Qui, il reducer è dichiarato nella parte in basso del file del componente:
import { useReducer } from 'react'; import AddTask from './AddTask.js'; import TaskList from './TaskList.js'; export default function TaskApp() { const [tasks, dispatch] = useReducer(tasksReducer, initialTasks); function handleAddTask(text) { dispatch({ type: 'added', id: nextId++, text: text, }); } function handleChangeTask(task) { dispatch({ type: 'changed', task: task, }); } function handleDeleteTask(taskId) { dispatch({ type: 'deleted', id: taskId, }); } return ( <> <h1>Prague itinerary</h1> <AddTask onAddTask={handleAddTask} /> <TaskList tasks={tasks} onChangeTask={handleChangeTask} onDeleteTask={handleDeleteTask} /> </> ); } function tasksReducer(tasks, action) { switch (action.type) { case 'added': { return [ ...tasks, { id: action.id, text: action.text, done: false, }, ]; } case 'changed': { return tasks.map((t) => { if (t.id === action.task.id) { return action.task; } else { return t; } }); } case 'deleted': { return tasks.filter((t) => t.id !== action.id); } default: { throw Error('Unknown action: ' + action.type); } } } let nextId = 3; const initialTasks = [ {id: 0, text: 'Visit Kafka Museum', done: true}, {id: 1, text: 'Watch a puppet show', done: false}, {id: 2, text: 'Lennon Wall pic', done: false}, ];
Se vuoi, puoi anche spostare il reducer in un file diverso:
import { useReducer } from 'react'; import AddTask from './AddTask.js'; import TaskList from './TaskList.js'; import tasksReducer from './tasksReducer.js'; export default function TaskApp() { const [tasks, dispatch] = useReducer(tasksReducer, initialTasks); function handleAddTask(text) { dispatch({ type: 'added', id: nextId++, text: text, }); } function handleChangeTask(task) { dispatch({ type: 'changed', task: task, }); } function handleDeleteTask(taskId) { dispatch({ type: 'deleted', id: taskId, }); } return ( <> <h1>Prague itinerary</h1> <AddTask onAddTask={handleAddTask} /> <TaskList tasks={tasks} onChangeTask={handleChangeTask} onDeleteTask={handleDeleteTask} /> </> ); } let nextId = 3; const initialTasks = [ {id: 0, text: 'Visit Kafka Museum', done: true}, {id: 1, text: 'Watch a puppet show', done: false}, {id: 2, text: 'Lennon Wall pic', done: false}, ];
La logica del componente può essere più facile da leggere quando si ha una separation of concerns come in questo modo. Ora, gli event handler specificano solo cosa è successo inviando azioni, e la funzione reducer determina come si aggiorna lo state in risposta ad esse.
Confronto tra useState
e useReducer
I reducer non sono privi di svantaggi! Ecco alcuni modi per confrontarli:
- Dimensione del codice: Generalmente, con
useState
devi scrivere meno codice in anticipo. ConuseReducer
, devi scrivere sia una funzione reducer che azioni di dispatch. Tuttavia,useReducer
può aiutare a ridurre il codice se molti event handler modificano lo stato in modo simile. - Leggibilità:
useState
è molto facile da leggere quando gli aggiornamenti dello state sono semplici. Quando diventano più complessi, possono gonfiare il codice del tuo componente e renderlo difficile da esaminare. In questo caso,useReducer
ti permette di separare in modo pulito il come della logica di aggiornamento dal cosa è successo degli event handler. - Debugging: Quando hai un bug con
useState
, può essere difficile capire dove lo stato è stato impostato in modo errato, e perché. ConuseReducer
, puoi aggiungere un log della console nel tuo reducer per vedere ogni aggiornamento dello state e perché è successo (a causa di qualeazione
). Se ogniazione
è corretta, saprai che l’errore è nella logica del reducer stesso. Tuttavia, devi passare attraverso più codice rispetto auseState
. - Testing: Un reducer è una funzione pura che non dipende dal tuo componente. Questo significa che puoi esportarlo e testarlo separatamente in isolamento. Anche se generalmente è meglio testare i componenti in un ambiente più realistico, per la logica di aggiornamento dello stato complessa può essere utile affermare che il tuo reducer restituisce un particolare stato per un particolare stato iniziale e azione.
- Preferenza personale: Ad alcune persone piacciono i reducers, ad altre no. Va bene. È una questione di preferenza. Puoi sempre passare da
useState
auseReducer
e viceversa: sono equivalenti!
Raccomandiamo l’uso di un reducer se spesso incontri bug dovuti ad aggiornamenti errati di state in qualche componente, e desideri introdurre più struttura nel suo codice. Non devi usare i reducer per tutto: sentiti libero di combinare e variare! Puoi anche usare useState
e useReducer
nello stesso componente.
Scrivere bene i reducer
Tieni a mente questi due suggerimenti quando scrivi i reducer:
- I reducer devono essere puri. Similmente alle funzioni di aggiornamento dello stato, i reducer vengono eseguiti durante il rendering! (Le azioni vengono messe in coda fino al prossimo render.) Questo significa che i reducer devono essere puri: gli stessi input producono sempre lo stesso output. Non dovrebbero inviare richieste, programmare timeout, o eseguire side effect (operazioni che impattano cose al di fuori del componente). Dovrebbero aggiornare oggetti e array senza mutazioni.
- Ogni azione descrive un’unica interazione dell’utente, anche se ciò comporta molteplici cambiamenti nei dati. Ad esempio, se un utente preme “Reset” su un modulo con cinque campi gestiti da un reducer, ha più senso inviare una sola azione
reset_form
piuttosto che cinque azioniset_field
separate. Se registri ogni azione in un reducer, quel registro (log) dovrebbe essere abbastanza chiaro da permetterti di ricostruire quali interazioni o risposte sono avvenute e in che ordine. Questo ti aiuta con il debugging!
Scrivere reducers concisi con Immer
Esattamente come con l’aggiornamento degli oggetti e degli array nello state ordinario, puoi usare la libreria Immer per rendere i reducer più concisi. Qui, useImmerReducer
ti permette di mutare lo stato con push
o l’assegnazione arr[i] =
:
{ "dependencies": { "immer": "1.7.3", "react": "latest", "react-dom": "latest", "react-scripts": "latest", "use-immer": "0.5.1" }, "scripts": { "start": "react-scripts start", "build": "react-scripts build", "test": "react-scripts test --env=jsdom", "eject": "react-scripts eject" }, "devDependencies": {} }
I reducer devono essere puri, quindi non dovrebbero mutare lo state. Ma Immer ti fornisce un oggetto draft
speciale che è sicuro da mutare. Dietro le quinte, Immer creerà una copia del tuo state con le modifiche che hai apportato al draft
. Questo è il motivo per cui i reducer gestiti da useImmerReducer
possono mutare il loro primo argomento e non hanno bisogno di ritornare lo state.
Riepilogo
- Per passare da
useState
auseReducer
:- Esegui il dispatch delle azioni dagli event handler.
- Scrivi una funzione reducer che restituisce il prossimo state per un dato state e una action.
- Sostituisci
useState
conuseReducer
.
- I reducer richiedono di scrivere un po’ più di codice, ma facilitano il debugging e il testing.
- I reducer devono essere puri.
- Ogni azione descrive una singola interazione dell’utente.
- Utilizza Immer se desideri scrivere reducer in uno stile che preveda mutazioni.
Sfida 1 di 4: Esegui il dispatch delle azioni dagli event handlers
Attualmente, gli event handlers in ContactList.js
e Chat.js
hanno commenti // TODO
. Questo è il motivo per cui digitare nell’input non funziona e fare click sui pulsanti non cambia il destinatario selezionato.
Sostituisci questi due // TODO
con il codice per il dispatch
delle azioni corrispondenti. Per controllare la struttura prevista e il tipo delle azioni, controlla il reducer in messengerReducer.js
. Il reducer è già scritto quindi non avrai bisogno di cambiarlo. Devi solo eseguire il dispatch delle azioni in ContactList.js
e Chat.js
.
import { useReducer } from 'react'; import Chat from './Chat.js'; import ContactList from './ContactList.js'; import { initialState, messengerReducer } from './messengerReducer'; export default function Messenger() { const [state, dispatch] = useReducer(messengerReducer, initialState); const message = state.message; const contact = contacts.find((c) => c.id === state.selectedId); return ( <div> <ContactList contacts={contacts} selectedId={state.selectedId} dispatch={dispatch} /> <Chat key={contact.id} message={message} contact={contact} dispatch={dispatch} /> </div> ); } const contacts = [ {id: 0, name: 'Taylor', email: 'taylor@mail.com'}, {id: 1, name: 'Alice', email: 'alice@mail.com'}, {id: 2, name: 'Bob', email: 'bob@mail.com'}, ];