Extraindo a Lógica de Estado em um Redutor
Componentes com muitas atualizações de estado espalhadas por muitos manipuladores de eventos podem se tornar confusos. Para esses casos, você pode consolidar toda a lógica de atualização de estado fora do seu componente em uma única função, chamada de redutor.
Você aprenderá
- O que é uma função redutora
- Como refatorar
useState
parauseReducer
- Quando usar um redutor
- Como escrever um bem
Consolide a lógica de estado com um redutor
À medida que seus componentes crescem em complexidade, pode se tornar mais difícil ver rapidamente todas as diferentes maneiras pelas quais o estado de um componente é atualizado. Por exemplo, o componente TaskApp
abaixo mantém um array de tasks
no estado e usa três manipuladores de eventos 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>Roteiro de Praga</h1> <AddTask onAddTask={handleAddTask} /> <TaskList tasks={tasks} onChangeTask={handleChangeTask} onDeleteTask={handleDeleteTask} /> </> ); } let nextId = 3; const initialTasks = [ {id: 0, text: 'Visitar o Museu Kafka', done: true}, {id: 1, text: 'Assistir a um teatro de marionetes', done: false}, {id: 2, text: 'Foto do Muro Lennon', done: false}, ];
Cada um dos seus manipuladores de eventos chama setTasks
para atualizar o estado. À medida que esse componente cresce, também cresce a quantidade de lógica de estado espalhada por ele. Para reduzir essa complexidade e manter toda a sua lógica em um único lugar de fácil acesso, você pode mover essa lógica de estado para uma única função fora do seu componente, chamada de “redutor”.
Redutores são uma maneira diferente de lidar com estado. Você pode migrar de useState
para useReducer
em três etapas:
- Mover de definir estado para despachar ações.
- Escrever uma função redutora.
- Usar o redutor a partir do seu componente.
Etapa 1: Mover de definir estado para despachar ações
Seus manipuladores de eventos atualmente especificam o que fazer definindo 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));
}
Remova toda a lógica de configuração de estado. O que você terá são três manipuladores de eventos:
handleAddTask(text)
é chamado quando o usuário pressiona “Adicionar”.handleChangeTask(task)
é chamado quando o usuário alterna uma tarefa ou pressiona “Salvar”.handleDeleteTask(taskId)
é chamado quando o usuário pressiona “Excluir”.
Gerenciar estado com redutores é um pouco diferente de definir estado diretamente. Em vez de dizer ao React “o que fazer” definindo estado, você especifica “o que o usuário acabou de fazer” despachando “ações” a partir dos seus manipuladores de eventos. (A lógica de atualização de estado residirá em outro lugar!) Assim, em vez de “definir tasks
” via um manipulador de eventos, você está despachando uma ação “adicionada/alterada/excluída uma tarefa”. Isso é mais descritivo da intenção do usuário.
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ê passa para dispatch
é chamado de “ação”:
function handleDeleteTask(taskId) {
dispatch(
// objeto "ação":
{
type: 'deleted',
id: taskId,
}
);
}
É um objeto JavaScript comum. Você decide o que colocar nele, mas geralmente deve conter as informações mínimas sobre o que aconteceu. (Você adicionará a função dispatch
em uma etapa posterior.)
Etapa 2: Escrever uma função redutora
Uma função redutora é onde você colocará sua lógica de estado. Ela recebe dois argumentos, o estado atual e o objeto de ação, e retorna o próximo estado:
function yourReducer(state, action) {
// retorna o próximo estado para o React definir
}
O React definirá o estado para o que você retornar da redutora.
Para mover sua lógica de configuração de estado dos manipuladores de eventos para uma função redutora neste exemplo, você precisará:
- Declarar o estado atual (
tasks
) como o primeiro argumento. - Declarar o objeto
action
como o segundo argumento. - Retornar o próximo estado da redutora (que o React definirá como o estado).
Aqui está toda a lógica de configuração de estado migrada para uma função redutora:
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('Ação desconhecida: ' + action.type);
}
}
Como a função redutora leva o estado (tasks
) como um argumento, você pode declarar isso fora do seu componente. Isso diminui o nível de indentação e pode deixar seu código mais fácil de ler.
Deep Dive
Embora os redutores possam “reduzir” a quantidade de código dentro do seu componente, eles na verdade têm esse nome devido à operação reduce()
que você pode realizar em arrays.
A operação reduce()
permite que você pegue um array e “acumule” um único valor a partir de muitos:
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 reduce
é conhecida como “redutor”. Ela recebe o resultado até agora e o item atual, em seguida, retorna o próximo resultado. Redutores do React são um exemplo da mesma ideia: eles pegam o estado até agora e a ação, e retornam o próximo estado. Dessa forma, eles acumulam ações ao longo do tempo em 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 redutora para ele:
import tasksReducer from './tasksReducer.js'; let initialState = []; let actions = [ {type: 'added', id: 1, text: 'Visitar o Museu Kafka'}, {type: 'added', id: 2, text: 'Assistir a um teatro de marionetes'}, {type: 'deleted', id: 1}, {type: 'added', id: 3, text: 'Foto do Muro Lennon'}, ]; let finalState = actions.reduce(tasksReducer, initialState); const output = document.getElementById('output'); output.textContent = JSON.stringify(finalState, null, 2);
Você provavelmente não precisará fazer isso você mesmo, mas é semelhante ao que o React faz!
Etapa 3: Usar o redutor a partir do seu componente
Finalmente, você precisa conectar o tasksReducer
ao seu componente. Importe o Hook useReducer
do React:
import { useReducer } from 'react';
Então você pode substituir useState
:
const [tasks, setTasks] = useState(initialTasks);
por useReducer
assim:
const [tasks, dispatch] = useReducer(tasksReducer, initialTasks);
O Hook useReducer
é semelhante ao useState
—você deve passar um estado inicial e ele retorna um valor com estado e uma forma de definir estado (neste caso, a função dispatch). Mas é um pouco diferente.
O Hook useReducer
leva dois argumentos:
- Uma função redutora
- Um estado inicial
E retorna:
- Um valor com estado
- Uma função dispatch (para “despachar” ações do usuário para o redutor)
Agora está totalmente conectado! Aqui, o redutor é declarado na parte inferior do 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>Roteiro de Praga</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('Ação desconhecida: ' + action.type); } } } let nextId = 3; const initialTasks = [ {id: 0, text: 'Visitar o Museu Kafka', done: true}, {id: 1, text: 'Assistir a um teatro de marionetes', done: false}, {id: 2, text: 'Foto do Muro Lennon', done: false}, ];
Se você quiser, você pode até mover o redutor 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>Roteiro de Praga</h1> <AddTask onAddTask={handleAddTask} /> <TaskList tasks={tasks} onChangeTask={handleChangeTask} onDeleteTask={handleDeleteTask} /> </> ); } let nextId = 3; const initialTasks = [ {id: 0, text: 'Visitar o Museu Kafka', done: true}, {id: 1, text: 'Assistir a um teatro de marionetes', done: false}, {id: 2, text: 'Foto do Muro Lennon', done: false}, ];
A lógica do componente pode ser mais fácil de ler quando você separa as responsabilidades assim. Agora os manipuladores de eventos apenas especificam o que aconteceu despachando ações, e a função redutora determina como o estado é atualizado em resposta a elas.
Comparando useState
e useReducer
Redutores não estão sem desvantagens! Aqui estão algumas maneiras de você compará-los:
- Tamanho do código: Geralmente, com
useState
você precisa escrever menos código no início. ComuseReducer
, você precisa escrever tanto uma função redutora quanto despachar ações. No entanto,useReducer
pode ajudar a reduzir o código se muitos manipuladores de eventos modificam o estado de maneira semelhante. - Legibilidade:
useState
é muito fácil de ler quando as atualizações de estado são simples. Quando elas se tornam mais complexas, podem inchar o código do seu componente e dificultar a verificação. Nesse caso,useReducer
permite que você separe claramente o como da lógica de atualização do o que aconteceu dos manipuladores de eventos. - Depuração: Quando você tem um erro com
useState
, pode ser difícil dizer onde o estado foi definido incorretamente, e por que. ComuseReducer
, você pode adicionar um console log dentro do seu redutor para ver cada atualização de estado, e por que isso aconteceu (devido a qualação
). Se cadaação
estiver correta, você saberá que o erro está na lógica do redutor em si. No entanto, você tem que percorrer mais código do que comuseState
. - Teste: Um redutor é uma função pura que não depende do seu componente. Isso significa que você pode exportá-lo e testá-lo separadamente em isolamento. Embora geralmente seja melhor testar componentes em um ambiente mais realista, para lógica de atualização de estado complexa pode ser útil afirmar que seu redutor retorna um estado específico para um determinado estado inicial e ação.
- Preferência pessoal: Algumas pessoas preferem redutores, outras não. Tudo bem. É uma questão de preferência. Você sempre pode converter entre
useState
euseReducer
repetidamente: eles são equivalentes!
Recomendamos usar um redutor se você frequentemente encontrar erros devido a atualizações de estado incorretas em algum componente e quiser introduzir mais estrutura ao seu código. Você não precisa usar redutores para tudo: sinta-se à vontade para misturar e combinar! Você pode até usar useState
e useReducer
no mesmo componente.
Escrevendo redutores bem
Mantenha estas duas dicas em mente ao escrever redutores:
- Redutores devem ser puros. Semelhante às funções de atualização de estado, redutores são executados durante a renderização! (As ações são enfileiradas até a próxima renderização.) Isso significa que os redutores devem ser puros—as mesmas entradas sempre resultam na mesma saída. Eles não devem enviar solicitações, agendar timeouts ou realizar quaisquer 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 interação do usuário, mesmo que isso leve a várias mudanças nos dados. Por exemplo, se um usuário pressiona “Redefinir” em um formulário com cinco campos gerenciados por um redutor, faz mais sentido despachar uma ação
reset_form
em vez de cinco açõesset_field
separadas. Se você registrar cada ação em um redutor, esse registro deve ser claro o suficiente para você reconstruir quais interações ou respostas aconteceram em que ordem. Isso ajuda na depuração!
Escrevendo redutores concisos com Immer
Assim como com atualizando objetos e arrays em estado regular, você pode usar a biblioteca Immer para tornar os redutores mais concisos. Aqui, useImmerReducer
permite que você mutile o estado com push
ou atribuição 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 redutores devem ser puros, então eles não devem mutar o estado. Mas o Immer fornece a você um objeto draft
especial que é seguro para mutar. Nos bastidores, o Immer irá criar uma cópia do seu estado com as alterações que você fez no draft
. É por isso que redutores gerenciados por useImmerReducer
podem mutar seu primeiro argumento e não precisam retornar estado.
Recap
- Para converter de
useState
parauseReducer
:- Despache ações a partir dos manipuladores de eventos.
- Escreva uma função redutora que retorna o próximo estado para um dado estado e ação.
- Substitua
useState
poruseReducer
.
- Redutores exigem que você escreva um pouco mais de código, mas ajudam com depuração e teste.
- Redutores devem ser puros.
- Cada ação descreve uma única interação do usuário.
- Use Immer se você quiser escrever redutores em um estilo mutável.
Challenge 1 of 3: Despachar ações a partir dos manipuladores de eventos
Atualmente, os manipuladores de eventos em ContactList.js
e Chat.js
têm comentários // TODO
. Por isso, digitar na entrada não funciona, e clicar nos botões não muda o destinatário selecionado.
Substitua esses dois // TODO
s pelo código para despachar
as ações correspondentes. Para ver a forma esperada e o tipo das ações, verifique o redutor em messengerReducer.js
. O redutor já está escrito, então você não precisará mudá-lo. Você só precisa despachar 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'}, ];