Extraindo Lógica de Estado Em Um Reducer

Componentes com atualizações de estado espalhadas por alguns event handlers podem ficar muito pesados. Para estes casos, você pode consolidar toda a lógica de atualização de estado fora do seu componente em uma única função, chamada de reducer.

Você aprenderá

  • O que é uma função reducer
  • Como refatorar useState para useReducer
  • Quando usar um reducer
  • Como escrever bem um

Consolidar a lógica de estado com um reducer

À medida que seus componentes crescem em complexidade, pode ficar difícil de ver em um olhar todas as diferentes formas pelas quais um estado de componente atualiza. Por exemplo, o componente TaskApp abaixo segura um array de tasks no estado e usa três event handlers diferentes para adicionar, remover e editar tarefas:

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},
];

Cada um de seus event handlers chama setTasks em ordem para atualizar o estado. À medida que o componente cresce, então a lógica de estado se espalha por toda parte. Para reduzir essa complexidade e manter toda a lógica em um lugar fácil de acessar, você pode mover aquela lógica de estado em uma função única fora do seu componente, chamado reducer.

Os reducers são uma forma diferente de lidar com estado. Você pode migrar de setState para useReducer em três passos:

  1. Mover de definir estado para disparo de ações.
  2. Escrever uma função reducer.
  3. Usar o reducer no seu componente.

Passo 1: Mover de definir estado para disparo de ações

Seus event handlers atualmente especificam o que fazer ao definir o estado:

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

Remover toda a lógica que define o estado. O que você deixa são três event handlers:

  • handleAddTask(text) é chamado quando o usuário pressiona “Adicionar”.
  • handleChangeTask(task) é chamado quando o usuário muda uma tarefa ou pressiona “Salvar”.
  • handleDeleteTask(taskId) é chamado quando o usuário pressiona “Remover”.

O gerenciamento de estado com reducers é um pouco diferente de definir o estado diretamente. Em vez de dizer ao React “o que fazer” definindo o estado, você especifica “o que o usuário acabou de fazer” disparando “actions”(“ações”) do seus event handlers. (A lógica de atualização de estado vai sobreviver em outro lugar!) Então em vez de “definir tasks” através de um event handler, você está disparando um evento de tarefa “adicionada/mudada/removida”. Isto é mais descritivo à respeito do que o usuário pretende fazer.

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

O objeto que você passou para dispatch é chamado de “action”(“ação”):

function handleDeleteTask(taskId) {
dispatch(
// "action" object:
{
type: 'deleted',
id: taskId,
}
);
}

É um objeto regular do JavaScript. Você decide o que colocar nele, mas geralmente isso pode conter o mínimo de informações sobre o que aconteceu. (Você vai adicionar a função dispatch em si mesmo em um último passo.)

Note

Um objeto de ação pode ter qualquer forma.

Por convenção, é comum dar uma string type que descreve o que aconteceu, e passar qualquer informação adicional em outros campos. O type é especifico para um componente, então nesse exemplo tanto 'added' ou 'added_task' seriam bom. Escolha um nome que diz o que aconteceu!

dispatch({
// specific to component
type: 'what_happened',
// other fields go here
});

Passo 2: Escrever uma função de reducer

Uma função reducer é onde você vai colocar a sua lógica de estado. Ele recebe dois argumentos, o estado atual e o objeto de ação, e retorna o próximo estado:

function yourReducer(state, action) {
// return next state for React to set
}

O React vai definir o estado para o que você quer retornar do reducer.

Para mover sua lógica de definição de estado dos seus event handlers para uma função reducer neste exemplo, você vai:

  1. Declarar o estado atual (tasks) como o primeiro argumento.
  2. Declarar o objeto action como o segundo argumento.
  3. Retornar o próximo estado do reducer (para o qual o React vai definir o estado).

Aqui está toda a lógica de definição de estado migrada para uma função 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);
}
}

Por causa que a função reducer pega o estado (tasks) como um argumento, você pode declarar isso for do seu componente. Isto diminui a nível de indentação e pode fazer seu código ser facilmente lido.

Note

O código abaixo usa if/else, mas é uma convenção usar declarações de switch dentro dos reducers. O resultado é o mesmo, mas definições de switch podem ser mais facilmente lidas ao olhar.

Nós vamos estar usando eles por todo o resto dessa documentação como:

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

Nós recomendamos envolver cada bloco case dentro de { e } parenteses para que variáveis declaradas dentro de cases diferentes não dê choque com outro. Também, um case deve usualmente terminar com um return. Se você esquecer do return, o código vai “Cair” no próximo case, que pode resultar em erros!

Se você não estiver confortável com definições de switch, usar if/else é completamente tranquilo.

Deep Dive

Por que os reducers são chamados dessa forma?

Embora os reducers possam “reduzir” a quantidade dde código do seu componente, eles são na verdade chamados depois da operação reduce() que você pode performar em arrays.

A operação reduce() deixa você pegar um array e “acumular” um valor único a partir de alguns:

const arr = [1, 2, 3, 4, 5];
const sum = arr.reduce(
(result, number) => result + number
); // 1 + 2 + 3 + 4 + 5

A função que você passa para o reduce é conhecida como um “reducer”. Ele leva o resultado até agora e o item atual, depois retorna o próximo resultado. Os reducers do React são um exemplo da mesma ideia: eles levam o estado até agora e a ação, e retorna o próximo estado. Dessa forma, ele acumula ações em tempo no estado.

Você poderia até usar o método reduce() com um initialState e um array de actions para calcular o estado final passando sua função reducer para ele:

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

Você provavelmente não vai precisar fazer isso você mesmo, mas isso é parecido ao que o React faz!

Passo 3: Usar o reducer no seu componente

Finalmente, você precisa ligar o tasksReducer em seu componente. Importe o Hook do React useReducer:

import { useReducer } from 'react';

Depois você pode trocar o useState:

const [tasks, setTasks] = useState(initialTasks);

por useReducer dessa forma:

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

O Hook useReducer é parecido ao useState—você deve passá-lo um estado inicial e retorná-lo um valor com estado e uma forma de definir o estado (nesse caso, a função de disparo). Mas é um pouco diferente.

O Hook useReducer recebe dois argumentos:

  1. Uma função reducer
  2. Um estado inicial

E retorna:

  1. Um valor com estado
  2. Uma função de desparo (para “disparar” as ações do usuário para o reducer)

Agora está conectado! Aqui, o reducer é declarado abaixo no arquivo do 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 você quiser, você pode até mover o reducer para um arquivo diferente:

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},
];

A lógica do componente pode ser facilmente lida quando você separa preocupações como esta. Agora os event handlers apenas especificam o que aconteceu pelo disparo das ações, e a função reducer determina como o estado atualiza na resposta à eles.

Comparando useState e useReducer

Os reducers não estão sem quedas! Aqui está alguns caminhos que você pode comparar com eles:

  • Tamanho do código: Geralmente, com useState você tem que escrever menos código adiantado. Com useReducer, você tem que escrever uma função reducer e disparar ações. Toda via, o useReducer pode ajudar a rasgar no código se alguns event handlers modificam estado de uma forma parecida.
  • Legibilidade: O useState é muito fácil de ler quando a atualização do estado é simples. Quando eles ficam mais complexos, eles podem inchar o código do seu componente e ficar mais difícil de escanear. Nesse caso, o useReducer deixa você separar de forma limpa como da lógica de atualização de o que aconteceu dos event handlers.
  • Debugando: Quando você tem um erro com useState, pode ficar difícil de dizer onde o estado foi definido de maneira incorreta, e porque isso ocorreu (devido à qual action). Se cada action está correta, você vai saber que o erro está na própria lógica do reducer. Toda via, você tem que percorrer mais código do que com o useState.
  • Testando: Um reducer é uma função pura que não depende de seu componente. Isso significa que você pode exportar e testá-lo separadamente em isolado. Enquanto geralmente é melhor testar componentes em um ambiente mais real, para lógica de atualização de estado complexa isso pode ser usual para afirmar que o seu reducer retorna um estado particular por um estado inicial particular e ação.)
  • Preferência pessoal: Algumas pessoas gostam de reducers, outras não. Está tudo bem. Não importa a preferência. Você sempre pode converter entre useState e useReducer de volta e diante: eles são equivalentes!

Nós recomendamos usar um reducer se você encontrar bugs frequentemente devido à atualização de estado incorreta em algum componente, e quer introduzir mais estrutura ao seu código. Você não tem que usar reducers para tudo: sinta-se livre para misturar e combinar! Você pode até mesmo usar useState e useReducer no mesmo componente.

Escrevendo reducers bem

Mantenha estas duas dicas em mente quando estiver escrevendo reducers:

  • Os reducers devem ser puros: Parecido com funções de atualização de estado, os reducers rodam durante a renderização! (As actions (ações) são enfileiradas até a próxima renderização.) Isso significa que os reducers devem ser puros-as mesmas entradas resultam na mesma saída. Eles não devem enviar requisições, timeouts cronometrados ou performar efeitos colaterais (operações que impactam coisas fora do componente). Eles devem atualizar objetos e arrays sem mutações.
  • Cada ação descreve uma única iteração do usuário, mesmo que isso leve à várias mudanças no dado. Por exemplo, se um usuário pressionar “Reset” em um formulário com cinco campos gerenciados por um reducer, faz mais sentido disparar uma ação reset_form do que cinco ações set_field separadas. Se você registrar toda ação em um reducer, aquele registro deve ser limpo o suficiente para você reconstruir quais interações ou respostas ocorreram em qual ordem. Isso ajuda a debugar!

Escrever reducers concisos com Immer

Apenas como atualização de objetos e arrays em estado regular, você pode usar a biblioteca Immer para fazer reducers mais concisos. Aqui, useImmerReducer deixa você mudar o estado com as assinaturas push ou 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": {}
}

Os reducers devem ser puros, então eles não devem mudar o estado. Mas Immer lhe provê um objeto draft especial do qual é seguro mudar. Por baixo dos panos, Immer vai criar uma cópia so seu estado com mudanças que você fez para o draft. Este é o porquê de os reducers gerenciados por useImmerReducer podem mudar seu primeiro argumento e não precisam retornar o estado.

Recap

  • Para converter de useState para useReducer:
    1. Disparar ações de event handlers.
    2. Escrever uma função reducer que retorna o próximo estado por um dado estado e ação.
    3. Trocar useState com useReducer.
  • Os reducers precisam que você escreva um pouco de código, mas eles ajudam a debugar e testar.
  • Os reducers devem ser puros.
  • Cada ação descreve uma única interação de usuário.
  • Use Immer se você quer escrever reducers em um estilo mutável.

Challenge 1 of 4:
Disparar ações de event handlers

Atualmente, os event handlers em ContactList.js e Chat.js têm // TODO comentários. Isto é o por quê digitar no input não funciona, e clicar nos botões não mudam o recebedor selecionado.

Trocar estes dois // TODOs com o código para dispatch as ações correspondentes. Para ver a forma esperada e o tipo das ações, verifique o reducer em messengerReducer.js. O reducer está escrito então você não precisa mudá-lo. Você só precisa disparar as ações em 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'},
];