Scaling Up with Reducer and Context

Reducers permitem consolidar a lógica de atualização de estado de um componente. Context permite passar informações profundamente para outros componentes. Você pode combinar reducers e context juntos para gerenciar o estado de uma tela complexa.

Você aprenderá

  • Como combinar um reducer com context
  • Como evitar passar estado e dispatch através de props
  • Como manter a lógica de context e estado em um arquivo separado

Combinando um reducer com context

Neste exemplo da introdução aos reducers, o estado é gerenciado por um reducer. A função reducer contém toda a lógica de atualização de estado e é declarada no final deste arquivo:

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>Day off in 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('Unknown action: ' + action.type);
    }
  }
}

let nextId = 3;
const initialTasks = [
  { id: 0, text: 'Philosopher’s Path', done: true },
  { id: 1, text: 'Visit the temple', done: false },
  { id: 2, text: 'Drink matcha', done: false }
];

Um reducer ajuda a manter os manipuladores de eventos curtos e concisos. No entanto, à medida que seu aplicativo cresce, você pode encontrar outra dificuldade. Atualmente, o estado tasks e a função dispatch estão disponíveis apenas no componente de nível superior TaskApp. Para permitir que outros componentes leiam a lista de tarefas ou a alterem, você precisa passar explicitamente o estado atual e os manipuladores de eventos que o alteram como props.

Por exemplo, TaskApp passa uma lista de tarefas e os manipuladores de eventos para TaskList:

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

E TaskList passa os manipuladores de eventos para Task:

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

Em um exemplo pequeno como este, isso funciona bem, mas se você tiver dezenas ou centenas de componentes no meio, passar todas as props de estado e funções pode ser bastante frustrante!

É por isso que, como alternativa a passá-las através de props, você pode querer colocar tanto o estado tasks quanto a função dispatch em um context. Dessa forma, qualquer componente abaixo de TaskApp na árvore pode ler as tarefas e despachar ações sem a repetitiva “prop drilling”.

Veja como você pode combinar um reducer com context:

  1. Crie o context.
  2. Coloque o estado e o dispatch no context.
  3. Use o context em qualquer lugar da árvore.

Etapa 1: Crie o context

O Hook useReducer retorna o tasks atual e a função dispatch que permite atualizá-los:

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

Para passá-los pela árvore, você criará dois contexts separados:

  • TasksContext fornece a lista atual de tarefas.
  • TasksDispatchContext fornece a função que permite aos componentes despachar ações.

Exporte-os de um arquivo separado para que você possa importá-los posteriormente de outros arquivos:

import { createContext } from 'react';

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

Aqui, você está passando null como valor padrão para ambos os contexts. Os valores reais serão fornecidos pelo componente TaskApp.

Etapa 2: Coloque o estado e o dispatch no context

Agora você pode importar ambos os contexts em seu componente TaskApp. Pegue os tasks e dispatch retornados por useReducer() e forneça-os para toda a árvore abaixo:

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

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

Por enquanto, você passa as informações tanto via props quanto em 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 value={tasks}>
      <TasksDispatchContext value={dispatch}>
        <h1>Day off in Kyoto</h1>
        <AddTask
          onAddTask={handleAddTask}
        />
        <TaskList
          tasks={tasks}
          onChangeTask={handleChangeTask}
          onDeleteTask={handleDeleteTask}
        />
      </TasksDispatchContext>
    </TasksContext>
  );
}

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: 'Philosopher’s Path', done: true },
  { id: 1, text: 'Visit the temple', done: false },
  { id: 2, text: 'Drink matcha', done: false }
];

Na próxima etapa, você removerá a passagem de props.

Etapa 3: Use o context em qualquer lugar da árvore

Agora você não precisa passar a lista de tarefas ou os manipuladores de eventos pela árvore:

<TasksContext value={tasks}>
<TasksDispatchContext value={dispatch}>
<h1>Dia de folga em Kyoto</h1>
<AddTask />
<TaskList />
</TasksDispatchContext>
</TasksContext>

Em vez disso, qualquer componente que precise da lista de tarefas pode lê-la do TasksContext:

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

Para atualizar a lista de tarefas, qualquer componente pode ler a função dispatch do context e chamá-la:

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

O componente TaskApp não passa nenhum manipulador de eventos para baixo, e o TaskList também não passa nenhum manipulador de eventos para o componente Task. Cada componente lê o context que precisa:

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)}>
          Save
        </button>
      </>
    );
  } else {
    taskContent = (
      <>
        {task.text}
        <button onClick={() => setIsEditing(true)}>
          Edit
        </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
        });
      }}>
        Delete
      </button>
    </label>
  );
}

O estado ainda “vive” no componente TaskApp de nível superior, gerenciado com useReducer. Mas seus tasks e dispatch agora estão disponíveis para todos os componentes abaixo na árvore, importando e usando esses contexts.

Mover toda a lógica de conexão para um único arquivo

Você não precisa fazer isso, mas pode refinar ainda mais os componentes movendo o reducer e o context para um único arquivo. Atualmente, TasksContext.js contém apenas duas declarações de context:

import { createContext } from 'react';

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

Este arquivo está prestes a ficar cheio! Você moverá o reducer para esse mesmo arquivo. Em seguida, declarará um novo componente TasksProvider no mesmo arquivo. Este componente unirá todas as peças:

  1. Ele gerenciará o estado com um reducer.
  2. Ele fornecerá ambos os contexts para os componentes abaixo.
  3. Ele receberá children como prop para que você possa passar JSX para ele.
export function TasksProvider({ children }) {
const [tasks, dispatch] = useReducer(tasksReducer, initialTasks);

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

Isso remove toda a complexidade e a lógica de conexão do seu componente TaskApp:

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

export default function TaskApp() {
  return (
    <TasksProvider>
      <h1>Day off in Kyoto</h1>
      <AddTask />
      <TaskList />
    </TasksProvider>
  );
}

Você também pode exportar funções que usam o context de TasksContext.js:

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

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

Quando um componente precisar ler o context, ele poderá fazê-lo através dessas funções:

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

Isso não altera o comportamento de forma alguma, mas permite que você divida esses contexts posteriormente ou adicione alguma lógica a essas funções. Agora toda a lógica de context e reducer está em TasksContext.js. Isso mantém os componentes limpos e organizados, focados no que exibem em vez de onde obtêm os dados:

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)}>
          Save
        </button>
      </>
    );
  } else {
    taskContent = (
      <>
        {task.text}
        <button onClick={() => setIsEditing(true)}>
          Edit
        </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
        });
      }}>
        Delete
      </button>
    </label>
  );
}

Você pode pensar em TasksProvider como parte da tela que sabe como lidar com tarefas, useTasks como uma forma de lê-las e useTasksDispatch como uma forma de atualizá-las de qualquer componente abaixo na árvore.

Note

Funções como useTasks e useTasksDispatch são chamadas de Custom Hooks. Sua função é considerada um custom Hook se o nome dela começar com use. Isso permite que você use outros Hooks, como useContext, dentro dela.

À medida que seu aplicativo cresce, você pode ter muitos pares de context-reducer como este. Esta é uma maneira poderosa de escalar seu aplicativo e elevar o estado sem muito trabalho sempre que quiser acessar os dados profundamente na árvore.

Recap

  • Você pode combinar reducer com context para permitir que qualquer componente leia e atualize o estado acima dele.
  • Para fornecer o estado e a função de dispatch aos componentes abaixo:
    1. Crie dois contexts (para estado e para funções de dispatch).
    2. Forneça ambos os contexts do componente que usa o reducer.
    3. Use qualquer um dos contexts dos componentes que precisam lê-los.
  • Você pode refinar ainda mais os componentes movendo toda a lógica de conexão para um único arquivo.
    • Você pode exportar um componente como TasksProvider que fornece context.
    • Você também pode exportar custom Hooks como useTasks e useTasksDispatch para lê-lo.
  • Você pode ter muitos pares de context-reducer como este em seu aplicativo.