Ampliare con Reducer e Context

I Reducers ti permettono di consolidare la logica di modifica dello stato di un componente. Il Context ti permette di passare informazioni in profondità ad altri componenti. Puoi combinare i reducers e i context insieme per gestire lo stato di una schermata complessa.

Imparerai

  • Come combinare un reducer con il context
  • Come evitare di passare lo stato e dispatch attraverso i parametri
  • Come mantenere context e logica dello stato in un file separato

Combinare un reducer con un context

In questo esempio da introduzione ai reducers, lo stato è gestito da un reducer. La funzione del reducer contiene tutta la logica di modifica dello stato ed é dichiarata in fondo al file:

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>Giorno di ferie a Kyoto</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('Azione sconosciuta: ' + action.type);
    }
  }
}

let nextId = 3;
const initialTasks = [
  { id: 0, text: 'Il sentiero del filosofo', done: true },
  { id: 1, text: 'Visita al tempio', done: false },
  { id: 2, text: 'Bevi matcha', done: false }
];

Un reducer aiuta a mantenere i gestori degli eventi brevi e concisi. Tuttavia, man mano che la tua app cresce, potresti incontrare un’altro tipo di difficoltà. Attualmente, lo stato tasks e la funzione dispatch sono disponibili solo nel componente di primo livello TaskApp. Per far in modo che gli altri componenti leggano la lista di tasks o la cambino, devi esplicitamente passare verso il basso lo stato attuale e i gestori degli eventi che la cambiano come parametri.

Per esempio, TaskApp passa la lista di tasks e i gestori degli eventi a TaskList:

<TaskList
tasks={tasks}
onChangeTask={handleChangeTask}
onDeleteTask={handleDeleteTask}
/>

E TaskList passa i gestori degli eventi a Task:

<Task
task={task}
onChange={onChangeTask}
onDelete={onDeleteTask}
/>

In un esempio breve come questo, questo metodo funziona bene, ma se hai decine o centinaia di componenti nel mezzo, passare tutto lo stato e tutte le funzioni può essere abbastanza frustrante!

Ed ecco perchè, come alternativa a passarli tramite parametri, vorresti poter mettere sia lo stato tasks sia la funzione dispatch nel contesto. In questo modo, ogni component al di sotto di TaskApp nell’albero può leggere le tasks e le azioni dispatch senza la ripetitiva “perforazione tramite parametri”.

Ecco come combinare un reducer con il context:

  1. Crea il context.
  2. Inserisci lo stato e il dispatch nel context.
  3. Usa il context ovunque nell’albero.

Passo 1: Crea il context

Il gancio useReducer ritorna le tasks attuali e la funzione dispatch che ti permette di aggiornarle:

const [tasks, dispatch] = useReducer(tasksReducer, initialTasks);

Per passarli in basso nell’albero, creerai due contesti separati:

  • Il TasksContext fornisce la lista corrente di tasks.
  • Il TasksDispatchContext fornisce la funzione che permette ai componenti l’invio di azioni.

Esportali in un file separato in modo da poter più tardi importarli in altri files:

import { createContext } from 'react';

export const TasksContext = createContext(null);
export const TasksDispatchContext = createContext(null);

Qui, stai passando null come il valore di default a tutti e due i context. I veri valori verranno forniti dal componente TaskApp.

Passo 2: Inserisci lo stato e il dispatch nel context

Ora puoi importare entrambi i context nel tuo componente TaskApp. Prendi tasks e dispatch ritornati da useReducer() e forniscili all’intero albero sottostante:

import { TasksContext, TasksDispatchContext } from './TasksContext.js';

export default function TaskApp() {
const [tasks, dispatch] = useReducer(tasksReducer, initialTasks);
// ...
return (
<TasksContext.Provider value={tasks}>
<TasksDispatchContext.Provider value={dispatch}>
...
</TasksDispatchContext.Provider>
</TasksContext.Provider>
);
}

Per ora, passa le informazioni sia tramite parametri che nel context:

import { useReducer } from 'react';
import AddTask from './AddTask.js';
import TaskList from './TaskList.js';
import { TasksContext, TasksDispatchContext } from './TasksContext.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 (
    <TasksContext.Provider value={tasks}>
      <TasksDispatchContext.Provider value={dispatch}>
        <h1>Giorno di ferie a Kyoto</h1>
        <AddTask
          onAddTask={handleAddTask}
        />
        <TaskList
          tasks={tasks}
          onChangeTask={handleChangeTask}
          onDeleteTask={handleDeleteTask}
        />
      </TasksDispatchContext.Provider>
    </TasksContext.Provider>
  );
}

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('Azione sconosciuta: ' + action.type);
    }
  }
}

let nextId = 3;
const initialTasks = [
  { id: 0, text: 'Il sentiero del filosofo', done: true },
  { id: 1, text: 'Visita al tempio', done: false },
  { id: 2, text: 'Bevi matcha', done: false }
];

Nel prossimo passo, rimuoverai il passaggio dei parametri.

Passo 3: Usa il context ovunque nell’albero

Ora non hai bisogno di passare la lista di tasks o i gestori di eventi nell’albero sottostante:

<TasksContext.Provider value={tasks}>
<TasksDispatchContext.Provider value={dispatch}>
<h1>Giorno di ferie a Kyoto</h1>
<AddTask />
<TaskList />
</TasksDispatchContext.Provider>
</TasksContext.Provider>

Al suo posto, ogni componente che ha bisogno della lista di task può leggerla dal TaskContext:

export default function TaskList() {
const tasks = useContext(TasksContext);
// ...

Per aggiornare la lista di task, ogni componente può leggere la funzione dispatch dal context e chiamarla:

export default function AddTask() {
const [text, setText] = useState('');
const dispatch = useContext(TasksDispatchContext);
// ...
return (
// ...
<button onClick={() => {
setText('');
dispatch({
type: 'added',
id: nextId++,
text: text,
});
}}>Aggiungi</button>
// ...

Il componente TaskApp non passa nessun gestore di eventi al di sotto, e neanche il TaskList non passa nessun gestore di eventi al Task. Ogni componente legge dal context ciò di cui ha bisogno:

import { useState, useContext } from 'react';
import { TasksContext, TasksDispatchContext } from './TasksContext.js';

export default function TaskList() {
  const tasks = useContext(TasksContext);
  return (
    <ul>
      {tasks.map(task => (
        <li key={task.id}>
          <Task task={task} />
        </li>
      ))}
    </ul>
  );
}

function Task({ task }) {
  const [isEditing, setIsEditing] = useState(false);
  const dispatch = useContext(TasksDispatchContext);
  let taskContent;
  if (isEditing) {
    taskContent = (
      <>
        <input
          value={task.text}
          onChange={e => {
            dispatch({
              type: 'changed',
              task: {
                ...task,
                text: e.target.value
              }
            });
          }} />
        <button onClick={() => setIsEditing(false)}>
          Salva
        </button>
      </>
    );
  } else {
    taskContent = (
      <>
        {task.text}
        <button onClick={() => setIsEditing(true)}>
          Modifica
        </button>
      </>
    );
  }
  return (
    <label>
      <input
        type="checkbox"
        checked={task.done}
        onChange={e => {
          dispatch({
            type: 'changed',
            task: {
              ...task,
              done: e.target.checked
            }
          });
        }}
      />
      {taskContent}
      <button onClick={() => {
        dispatch({
          type: 'deleted',
          id: task.id
        });
      }}>
        Rimuovi
      </button>
    </label>
  );
}

Lo stato ancora “vive” nel componente di primo livello TaskApp, gestito con useReducer. Ma i suoi tasks e dispatch sono ora disponibili ad ogni componente nell’albero sottostante semplicemente importando e usando questi context.

Spostare tutte le connessioni in un unico documento

Non devi necessariamente farlo, ma potresti dividere ulteriormente i componenti muovendo sia il reducer che il context in un unico file. Attualmente, TasksContext.js contiene solo due dichiarazioni:

import { createContext } from 'react';

export const TasksContext = createContext(null);
export const TasksDispatchContext = createContext(null);

Questo file sta per diventare affollato! Muovi il reducer nello stesso documento. Dopodichè dichiari il nuovo componente TasksProvider nello stesso file. Questo componente legherà tutti i pezzi insieme:

  1. Gestisce lo stato con un reducer.
  2. Fornisce entrambi i context ai componenti sottostanti.
  3. Prende children come parametro in modo da potergli passare JSX.
export function TasksProvider({ children }) {
const [tasks, dispatch] = useReducer(tasksReducer, initialTasks);

return (
<TasksContext.Provider value={tasks}>
<TasksDispatchContext.Provider value={dispatch}>
{children}
</TasksDispatchContext.Provider>
</TasksContext.Provider>
);
}

Questo rimuove tutta la complessità e connessioni dal tuo componente TaskApp:

import AddTask from './AddTask.js';
import TaskList from './TaskList.js';
import { TasksProvider } from './TasksContext.js';

export default function TaskApp() {
  return (
    <TasksProvider>
      <h1>Giorno di ferie a Kyoto</h1>
      <AddTask />
      <TaskList />
    </TasksProvider>
  );
}

Puoi anche esportare funzioni che usano il context da TasksContext.js:

export function useTasks() {
return useContext(TasksContext);
}

export function useTasksDispatch() {
return useContext(TasksDispatchContext);
}

Quando un componente ha bisogno di leggere il context, puoi farlo tramite queste funzioni:

const tasks = useTasks();
const dispatch = useTasksDispatch();

Questo non cambia il comportamento in alcun modo, ma ti permette di dividere più tardi ulteriormente questi context o di aggiungere della logica alle funzioni. Ora tutti i context e le connessioni dei reducer sono in TasksContext.js. Queto mantiene i componenti puliti e ordinati, concentrandosi su cosa mostrare piuttosto che dove prendere i dati:

import { useState } from 'react';
import { useTasks, useTasksDispatch } from './TasksContext.js';

export default function TaskList() {
  const tasks = useTasks();
  return (
    <ul>
      {tasks.map(task => (
        <li key={task.id}>
          <Task task={task} />
        </li>
      ))}
    </ul>
  );
}

function Task({ task }) {
  const [isEditing, setIsEditing] = useState(false);
  const dispatch = useTasksDispatch();
  let taskContent;
  if (isEditing) {
    taskContent = (
      <>
        <input
          value={task.text}
          onChange={e => {
            dispatch({
              type: 'changed',
              task: {
                ...task,
                text: e.target.value
              }
            });
          }} />
        <button onClick={() => setIsEditing(false)}>
          Salva
        </button>
      </>
    );
  } else {
    taskContent = (
      <>
        {task.text}
        <button onClick={() => setIsEditing(true)}>
          Modifica
        </button>
      </>
    );
  }
  return (
    <label>
      <input
        type="checkbox"
        checked={task.done}
        onChange={e => {
          dispatch({
            type: 'changed',
            task: {
              ...task,
              done: e.target.checked
            }
          });
        }}
      />
      {taskContent}
      <button onClick={() => {
        dispatch({
          type: 'deleted',
          id: task.id
        });
      }}>
        Rimuovi
      </button>
    </label>
  );
}

Puoi pensare a TasksProvider come una parte di una schermata che conosce come gestire le tasks, useTasks come un modo per leggerle e useTasksDispatch come al modo per aggiornarle da ogni componente ad un livello inferiore nell’albero.

Nota bene

Funzioni come useTasks e useTasksDispatch sono chiamate Ganci Personalizzati. La tua funzione è considerata un Gancio personalizzato se il nome comincia con use. Questo ti permette di usare altri Ganci, tipo useContext, dentro di esso.

Man mano che la tua applicazione cresce, potresti avere tante coppie context-reducer come questa. Questo è un modo potente per far crescere la tua app e portare lo stato in alto senza troppa fatica ogni volta che vuoi accedere ai dati in fondo all’albero.

Riepilogo

  • Puoi combinare reducer con il context per permettere ad ogni componente di leggere e modificare lo stato sopra di esso.
  • Per fornire lo stato e la funzione dispatch ai componenti sottostanti:
    1. Crea due context (per stato e per la funzione dispatch).
    2. Fornisci entrambi i context dal componente che usa il reducer.
    3. Usa uno dei due context dai componente che ne hanno bisogno per leggerli.
  • Puoi ulteriormente ordinare i componenti muovendo tutte le connessioni in un unico file.
    • Puoi esportare un componente come TasksProvider che fornisce il context.
    • Puoi anche esportare dei Ganci personalizzati come useTasks e useTasksDispatch per leggerlo.
  • Puoi avere molte coppie context-reducer come questa nella tua app.