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 in useReducer
  • 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:

  1. Sposta dal setting state alle azioni di dispatching.
  2. Scrivi una funzione di reducer.
  3. 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.)

Nota bene

Un oggetto action può avere qualsiasi forma.

Per convenzione, è comune dare un type stringa che descriva cosa è accaduto e per passare altre informazioni aggiuntive agli altri campi. Il type è specifico ad un componente, dunque in questo esempio sia 'added' che 'added_task' vanno bene. Scegli un nome che descriva cosa è accaduto!

dispatch({
// specifico del componente
type: 'what_happened',
// qua vanno gli altri campi
});

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:

  1. Dichiarare lo state corrente (tasks) come primo argomento.
  2. Dichiarare l’oggetto action come secondo argomento.
  3. 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.

Nota bene

Il codice sopra utilizza le istruzioni if/else, ma è una convenzione utilizzare le istruzioni switch all’interno dei reducer. Il risultato è lo stesso, ma le istruzioni switch possono essere più facili da leggere a colpo d’occhio.

Le useremo in tutto il resto di questa documentazione così:

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);
}
}
}

Consigliamo di racchiudere ciascun blocco case tra parentesi graffe { e } in modo che le variabili dichiarate all’interno di differenti case non entrino in conflitto tra loro. Inoltre, di solito un case dovrebbe terminare con un return. Se dimentichi di inserire return, il codice “scivolerà” nel case successivo, il che può portare a errori!

Se non ti senti ancora a tuo agio con le istruzioni switch, è del tutto accettabile utilizzare if/else.

Approfondimento

Perché i reducer vengono chiamati in questo modo?

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:

  1. Una funzione reducer
  2. Uno state iniziale

E restituisce:

  1. Un valore dello state
  2. 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. Con useReducer, 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é. Con useReducer, puoi aggiungere un log della console nel tuo reducer per vedere ogni aggiornamento dello state e perché è successo (a causa di quale azione). Se ogni azione è corretta, saprai che l’errore è nella logica del reducer stesso. Tuttavia, devi passare attraverso più codice rispetto a useState.
  • 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 a useReducer 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 azioni set_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 a useReducer:
    1. Esegui il dispatch delle azioni dagli event handler.
    2. Scrivi una funzione reducer che restituisce il prossimo state per un dato state e una action.
    3. Sostituisci useState con useReducer.
  • 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'},
];