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
parauseReducer
- 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:
- Mover de definir estado para disparo de ações.
- Escrever uma função reducer.
- 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.)
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:
- Declarar o estado atual (
tasks
) como o primeiro argumento. - Declarar o objeto
action
como o segundo argumento. - 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.
Deep Dive
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:
- Uma função reducer
- Um estado inicial
E retorna:
- Um valor com estado
- 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. ComuseReducer
, você tem que escrever uma função reducer e disparar ações. Toda via, ouseReducer
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, ouseReducer
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 à qualaction
). Se cadaaction
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 ouseState
. - 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
euseReducer
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çõesset_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
parauseReducer
:- Disparar ações de event handlers.
- Escrever uma função reducer que retorna o próximo estado por um dado estado e ação.
- Trocar
useState
comuseReducer
.
- 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 // TODO
s 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'}, ];