Компоненты с большим количеством обновлений состояния, распределенных по многим обработчикам событий, могут стать непомерно сложными. В таких случаях вы можете объединить всю логику обновления состояния за пределами вашего компонента в одной функции, называемой редуктором.
Вы узнаете
Что такое функция редуктора
Как рефакторить useState в useReducer
Когда использовать редуктор
Как написать редуктор
Консолидируйте логику состояния с помощью редуктора¶
По мере роста сложности ваших компонентов становится все труднее увидеть с первого взгляда все различные способы обновления состояния компонента. Например, компонент TaskApp ниже хранит массив tasks в состоянии и использует три различных обработчика событий для добавления, удаления и редактирования задач:
import{useState}from'react';importAddTaskfrom'./AddTask.js';importTaskListfrom'./TaskList.js';exportdefaultfunctionTaskApp(){const[tasks,setTasks]=useState(initialTasks);functionhandleAddTask(text){setTasks([...tasks,{id:nextId++,text:text,done:false,},]);}functionhandleChangeTask(task){setTasks(tasks.map((t)=>{if(t.id===task.id){returntask;}else{returnt;}}));}functionhandleDeleteTask(taskId){setTasks(tasks.filter((t)=>t.id!==taskId));}return(<><h1>Pragueitinerary</h1><AddTaskonAddTask={handleAddTask}/><TaskListtasks={tasks}onChangeTask={handleChangeTask}onDeleteTask={handleDeleteTask}/></>);}letnextId=3;constinitialTasks=[{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},];
Каждый из его обработчиков событий вызывает setTasks для обновления состояния. По мере роста этого компонента увеличивается и количество логики состояния, разбросанной по всему компоненту. Чтобы уменьшить эту сложность и держать всю логику в одном легкодоступном месте, вы можете переместить логику состояния в одну функцию вне вашего компонента, называемую "редуктором ".
Редукторы - это другой способ работы с состоянием. Вы можете перейти от useState к useReducer в три шага:
Переход от установки состояния к диспетчеризации действий.
Напишите функцию редуктора.
Используйте редуктор из вашего компонента.
Шаг 1: Переход от установки состояния к диспетчеризации действий¶
Ваши обработчики событий в настоящее время определяют что делать, устанавливая состояние:
Удалите всю логику установки состояния. Остаются только три обработчика событий:
handleAddTask(text) вызывается, когда пользователь нажимает кнопку "Добавить".
handleChangeTask(task) вызывается, когда пользователь переключает задачу или нажимает "Сохранить".
handleDeleteTask(taskId) вызывается, когда пользователь нажимает "Delete".
Управление состоянием с помощью редукторов несколько отличается от непосредственного задания состояния. Вместо того чтобы говорить React "что делать", устанавливая состояние, вы указываете, "что пользователь только что сделал", отправляя "действия" из ваших обработчиков событий. (Логика обновления состояния будет жить в другом месте!) Таким образом, вместо "установки tasks" через обработчик событий, вы отправляете действие "добавить/изменить/удалить задачу". Это более точно описывает намерения пользователя.
Это обычный объект JavaScript. Вы сами решаете, что в него поместить, но в целом он должен содержать минимальную информацию о том, что произошло. (Саму функцию dispatch вы добавите на следующем этапе).
Объект действия
Объект действия может иметь любую форму.
По общему правилу, принято задавать ему строку type, описывающую произошедшее, и передавать любую дополнительную информацию в других полях. Тип" специфичен для компонента, поэтому в данном примере подойдет либо 'added', либо 'added_task'. Выберите имя, которое говорит о том, что произошло!
12345
dispatch({// specific to componenttype:'what_happened',// other fields go here});
Редукторная функция - это то место, где вы будете размещать логику состояния. Она принимает два аргумента, текущее состояние и объект действия, и возвращает следующее состояние:
123
functionyourReducer(state,action){// return next state for React to set}
React установит состояние на то, что вы вернете из редуктора.
Чтобы перенести логику установки состояния из обработчиков событий в функцию редуктора в этом примере, необходимо:
Объявите текущее состояние (tasks) в качестве первого аргумента.
Объявить объект action в качестве второго аргумента.
Вернуть следующее состояние из редуктора (в которое React установит состояние).
Вот вся логика установки состояния, перенесенная в функцию reducer:
Поскольку функция reducer принимает состояние (tasks) в качестве аргумента, вы можете объявить его вне вашего компонента. Это уменьшает уровень отступов и может сделать ваш код более легким для чтения.
if или switch
В приведенном выше коде используются операторы if/else, но принято использовать операторы switch внутри редукторов. Результат тот же, но читать операторы switch с первого взгляда может быть проще.
Мы будем использовать их в остальной части этой документации следующим образом:
Мы рекомендуем заключать каждый блок case в фигурные скобки { и }, чтобы переменные, объявленные внутри разных case, не конфликтовали друг с другом. Кроме того, блок case обычно должен заканчиваться return. Если вы забудете return, код "провалится" в следующий case, что может привести к ошибкам!
Если вы еще не освоились с операторами switch, то вполне можно использовать if/else.
Почему редукторы называются именно так?
Хотя редукторы могут "уменьшить" количество кода в вашем компоненте, на самом деле они названы в честь операции reduce(), которую вы можете выполнять над массивами.
Операция reduce() позволяет вам взять массив и "накопить" одно значение из многих:
Функция, которую вы передаете в reduce, известна как "reducer". Она принимает результат на данный момент и текущий элемент, а затем возвращает следующий результат. React reducers - пример той же идеи: они принимают состояние на данный момент и действие, а возвращают следующее состояние. Таким образом, они накапливают действия со временем в состояние.
Вы даже можете использовать метод reduce() с initialState и массивом actions для вычисления конечного состояния, передав ему свою функцию reducer:
1 2 3 4 5 6 7 8 91011121314
importtasksReducerfrom'./tasksReducer.js';letinitialState=[];letactions=[{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'},];letfinalState=actions.reduce(tasksReducer,initialState);constoutput=document.getElementById('output');output.textContent=JSON.stringify(finalState,null,2);
Хук useReducer похож на useState - вы должны передать ему начальное состояние, а он возвращает значение состояния и способ установки состояния (в данном случае функцию диспетчеризации). Но он немного отличается.
Хук useReducer принимает два аргумента:
Функция редуктора
Начальное состояние
И возвращает:
Значение с состоянием
Диспетчерская функция (для "отправки" действий пользователя на редуктор).
Теперь он полностью подключен! Здесь редуктор объявлен в нижней части файла компонента:
Логику компонентов легче читать, когда вы разделяете проблемы подобным образом. Теперь обработчики событий только определяют что произошло, отправляя действия, а функция reducer определяет как обновляется состояние в ответ на них.
Редукторы не лишены недостатков! Вот несколько способов сравнить их:
Размер кода: Как правило, при использовании useState вам придется написать меньше кода. При использовании useReducer вам придется написать как функцию reducer, так и действия диспетчеризации. Однако useReducer может помочь сократить код, если многие обработчики событий изменяют состояние аналогичным образом.
Удобство чтения:useState очень легко читается, когда обновления состояния просты. Когда они становятся более сложными, они могут раздуть код вашего компонента и сделать его трудным для сканирования. В этом случае useReducer позволяет вам чисто отделить как логику обновления от что произошло обработчиков событий.
Отладка: Когда у вас есть ошибка с useState, может быть трудно сказать, где состояние было установлено неправильно, и почему. С useReducer вы можете добавить консольный журнал в ваш reducer, чтобы видеть каждое обновление состояния и почему это произошло (из-за какого action). Если каждое action корректно, вы будете знать, что ошибка в самой логике редуктора. Однако, вам придется просмотреть больше кода, чем при использовании useState.
Тестирование: Редуктор - это чистая функция, которая не зависит от вашего компонента. Это означает, что вы можете экспортировать и тестировать его отдельно, в изоляции. Хотя обычно лучше тестировать компоненты в более реалистичной среде, для сложной логики обновления состояния может быть полезно утверждать, что ваш редуктор возвращает определенное состояние для определенного начального состояния и действия.
Личные предпочтения: Некоторым людям нравятся редукторы, другим нет. Это нормально. Это вопрос предпочтений. Вы всегда можете конвертировать между useState и useReducer туда и обратно: они эквивалентны!
Мы рекомендуем использовать reducer, если вы часто сталкиваетесь с ошибками, связанными с некорректным обновлением состояния какого-либо компонента, и хотите внести больше структуры в его код. Вы не обязаны использовать редукторы для всего: смело сочетайте их друг с другом! Вы даже можете useState и useReducer в одном и том же компоненте.
Помните об этих двух советах при написании редукторов:
Редукторы должны быть чистыми. Подобно функциям обновления состояния, редукторы работают во время рендеринга! (Действия ставятся в очередь до следующего рендера.) Это означает, что редукторы должны быть чистыми - одинаковые входы всегда приводят к одинаковому выходу. Они не должны посылать запросы, планировать таймауты или выполнять какие-либо побочные эффекты (операции, которые влияют на вещи за пределами компонента). Они должны обновлять объекты и массивы без мутаций.
Каждое действие описывает одно взаимодействие пользователя, даже если оно приводит к нескольким изменениям данных. Например, если пользователь нажимает кнопку "Reset" на форме с пятью полями, управляемыми редуктором, логичнее отправить одно действие reset_form, чем пять отдельных действий set_field. Если вы регистрируете каждое действие в редукторе, этот журнал должен быть достаточно ясным, чтобы вы могли восстановить, какие взаимодействия или ответы происходили в каком порядке. Это помогает при отладке!
Так же, как и в случае с обновлением объектов и массивов в обычном состоянии, вы можете использовать библиотеку Immer, чтобы сделать редукторы более лаконичными. Здесь useImmerReducer позволяет вам мутировать состояние с помощью push или arr[i] = присваивания:
import{useImmerReducer}from'use-immer';importAddTaskfrom'./AddTask.js';importTaskListfrom'./TaskList.js';functiontasksReducer(draft,action){switch(action.type){case'added':{draft.push({id:action.id,text:action.text,done:false,});break;}case'changed':{constindex=draft.findIndex((t)=>t.id===action.task.id);draft[index]=action.task;break;}case'deleted':{returndraft.filter((t)=>t.id!==action.id);}default:{throwError('Unknown action: '+action.type);}}}exportdefaultfunctionTaskApp(){const[tasks,dispatch]=useImmerReducer(tasksReducer,initialTasks);functionhandleAddTask(text){dispatch({type:'added',id:nextId++,text:text,});}functionhandleChangeTask(task){dispatch({type:'changed',task:task,});}functionhandleDeleteTask(taskId){dispatch({type:'deleted',id:taskId,});}return(<><h1>Pragueitinerary</h1><AddTaskonAddTask={handleAddTask}/><TaskListtasks={tasks}onChangeTask={handleChangeTask}onDeleteTask={handleDeleteTask}/></>);}letnextId=3;constinitialTasks=[{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},];
Редукторы должны быть чистыми, поэтому они не должны мутировать состояние. Но Immer предоставляет вам специальный объект draft, который безопасен для мутации. Под капотом Immer создаст копию вашего состояния с изменениями, которые вы внесли в draft. Вот почему редукторы, управляемые useImmerReducer, могут мутировать свой первый аргумент и не должны возвращать состояние.
Итого
Чтобы перейти от useState к useReducer:
Отправляйте действия из обработчиков событий.
Напишите функцию-редуктор, которая возвращает следующее состояние для заданного состояния и действия.
Замените useState на useReducer.
Редукторы требуют написания большего количества кода, но они помогают при отладке и тестировании.
Редукторы должны быть чистыми.
Каждое действие описывает одно взаимодействие с пользователем.
Используйте Immer, если вы хотите писать редукторы в мутирующем стиле.
1. Диспетчеризация действий из обработчиков событий¶
В настоящее время обработчики событий в ContactList.js и Chat.js имеют комментарии // TODO. Именно поэтому ввод текста не работает, а нажатие на кнопки не изменяет выбранного получателя.
Замените эти два // TODO на код для dispatch соответствующих действий. Чтобы увидеть ожидаемую форму и тип действий, проверьте редуктор в messengerReducer.js. Редуктор уже написан, поэтому вам не придется его изменять. Вам нужно только диспетчеризировать действия в ContactList.js и Chat.js.
import{useState}from'react';exportdefaultfunctionChat({contact,message,dispatch,}){return(<sectionclassName="chat"><textareavalue={message}placeholder={'Chat to '+contact.name}onChange={(e)=>{// TODO: dispatch edited_message// (Read the input value from e.target.value)}}/><br/><button>Sendto{contact.email}</button></section>);}
Показать подсказку
Функция dispatch уже доступна в обоих компонентах, потому что она была передана в качестве пропса. Поэтому вам нужно вызвать dispatch с соответствующим объектом действия.
Чтобы проверить форму объекта действия, вы можете посмотреть на редуктор и увидеть, какие поля action он ожидает увидеть. Например, случай changed_selection в редукторе выглядит следующим образом:
Это означает, что объект вашего действия должен иметь type: 'changed_selection'. Вы также видите, что используется action.contactId, поэтому вам необходимо включить свойство contactId в ваше действие.
Показать решение
Из кода редуктора можно сделать вывод, что действия должны выглядеть следующим образом:
1 2 3 4 5 6 7 8 91011
// When the user presses "Alice"dispatch({type:'changed_selection',contactId:1,});// When user types "Hello!"dispatch({type:'edited_message',message:'Hello!',});
Вот пример, обновленный для отправки соответствующих сообщений:
import{useState}from'react';exportdefaultfunctionChat({contact,message,dispatch,}){return(<sectionclassName="chat"><textareavalue={message}placeholder={'Chat to '+contact.name}onChange={(e)=>{dispatch({type:'edited_message',message:e.target.value,});}}/><br/><button>Sendto{contact.email}</button></section>);}
import{useState}from'react';exportdefaultfunctionChat({contact,message,dispatch,}){return(<sectionclassName="chat"><textareavalue={message}placeholder={'Chat to '+contact.name}onChange={(e)=>{dispatch({type:'edited_message',message:e.target.value,});}}/><br/><button>Sendto{contact.email}</button></section>);}
Показать решение
Есть несколько способов сделать это в обработчике события кнопки "Отправить". Один из подходов - показать оповещение, а затем отправить действие edited_message с пустым message:
import{useState}from'react';exportdefaultfunctionChat({contact,message,dispatch,}){return(<sectionclassName="chat"><textareavalue={message}placeholder={'Chat to '+contact.name}onChange={(e)=>{dispatch({type:'edited_message',message:e.target.value,});}}/><br/><buttononClick={()=>{alert(`Sending "${message}" to ${contact.email}`);dispatch({type:'edited_message',message:'',});}}>Sendto{contact.email}</button></section>);}
Это работает и очищает ввод при нажатии кнопки "Отправить".
Однако, с точки зрения пользователя, отправка сообщения - это другое действие, чем редактирование поля. Чтобы отразить это, можно вместо этого создать новое действие под названием sent_message, и обрабатывать его отдельно в редукторе:
import{useState}from'react';exportdefaultfunctionChat({contact,message,dispatch,}){return(<sectionclassName="chat"><textareavalue={message}placeholder={'Chat to '+contact.name}onChange={(e)=>{dispatch({type:'edited_message',message:e.target.value,});}}/><br/><buttononClick={()=>{alert(`Sending "${message}" to ${contact.email}`);dispatch({type:'sent_message',});}}>Sendto{contact.email}</button></section>);}
В результате поведение будет одинаковым. Но имейте в виду, что типы действий в идеале должны описывать "что сделал пользователь", а не "как вы хотите, чтобы изменилось состояние". Это облегчает последующее добавление дополнительных функций.
При любом решении важно, чтобы вы не помещали alert внутри редуктора. Редуктор должен быть чистой функцией - он должен только вычислять следующее состояние. Он не должен ничего "делать", включая отображение сообщений пользователю. Это должно происходить в обработчике события. (Чтобы помочь отловить подобные ошибки, React будет вызывать ваши редукторы несколько раз в строгом режиме. Вот почему, если вы поместите оповещение в редуктор, оно сработает дважды).
3. Восстановление значений ввода при переключении между вкладками¶
В этом примере переключение между разными получателями всегда очищает текстовый ввод:
12345678
case'changed_selection':{return{...state,selectedId:action.contactId,message:''// Clears the input};// ...}
Это связано с тем, что вы не хотите разделять черновик одного сообщения между несколькими получателями. Но было бы лучше, если бы ваше приложение "запоминало" черновики для каждого контакта отдельно, восстанавливая их при переключении контактов.
Ваша задача - изменить структуру состояния таким образом, чтобы запоминать отдельный черновик сообщения для каждого контакта. Вам потребуется внести несколько изменений в редуктор, начальное состояние и компоненты.
import{useState}from'react';exportdefaultfunctionChat({contact,message,dispatch,}){return(<sectionclassName="chat"><textareavalue={message}placeholder={'Chat to '+contact.name}onChange={(e)=>{dispatch({type:'edited_message',message:e.target.value,});}}/><br/><buttononClick={()=>{alert(`Sending "${message}" to ${contact.email}`);dispatch({type:'sent_message',});}}>Sendto{contact.email}</button></section>);}
Показать подсказку
Вы можете структурировать свое состояние следующим образом:
1234567
exportconstinitialState={selectedId:0,messages:{0:'Hello, Taylor',// Draft for contactId = 01:'Hello, Alice',// Draft for contactId = 1},};
Синтаксис [key]: valuecomputed property поможет вам обновить объект messages:
1234
{...state.messages,[id]:message}
Показать решение
Вам нужно будет обновить редуктор, чтобы хранить и обновлять отдельный проект сообщения для каждого контакта:
1 2 3 4 5 6 7 8 910111213
// When the input is editedcase'edited_message':{return{// Keep other state like selection...state,messages:{// Keep messages for other contacts...state.messages,// But change the selected contact's message[state.selectedId]:action.message}};}
Вы также обновите компонент Messenger, чтобы прочитать сообщение для текущего выбранного контакта:
import{useState}from'react';exportdefaultfunctionChat({contact,message,dispatch,}){return(<sectionclassName="chat"><textareavalue={message}placeholder={'Chat to '+contact.name}onChange={(e)=>{dispatch({type:'edited_message',message:e.target.value,});}}/><br/><buttononClick={()=>{alert(`Sending "${message}" to ${contact.email}`);dispatch({type:'sent_message',});}}>Sendto{contact.email}</button></section>);}
Примечательно, что для реализации этого другого поведения вам не пришлось менять ни один из обработчиков событий. Без редуктора вам пришлось бы изменить каждый обработчик событий, который обновляет состояние.
В предыдущих примерах вы импортировали хук useReducer из React. В этот раз вам предстоит реализовать хук useReducer самостоятельно! Вот заглушка для начала работы. Он не должен занимать более 10 строк кода.
Чтобы проверить свои изменения, попробуйте ввести текст в поле ввода или выбрать контакт.
Напомним, что функция reducer принимает два аргумента - текущее состояние и объект действия - и возвращает следующее состояние. Что ваша реализация dispatch должна с ней делать?
Показать решение
Диспетчеризация действия вызывает редуктор с текущим состоянием и действием, и сохраняет результат как следующее состояние. Вот как это выглядит в коде: