Мы продолжаем изучать управления состоянием с Redux. На предыдущих уроках мы поняли, зачем нужны менеджеры состояний и разобрались с основными концепциями Redux: Store, Actions и Reducers.
Сегодня мы напишем наш первый редьюсер и стор так, как это делалось долгие годы до появления удобных инструментов, «голыми руками». Это нужно не для того, чтобы вы всегда так писали, а чтобы вы глубоко поняли, как данные текут в Redux и какую проблему решает Redux Toolkit.
Если кому нужно вспомнить React, у меня есть уроки по React для начинающих.
Знакомство с классическим редьюсером
Итак, что же такое редьюсер? Если отбросить всю суть, это просто функция. Но функция, которая следует строгим правилам.
Редьюсер это чистая функция, которая принимает текущее состояние (state) и действие (action) и возвращает новое состояние.
Давайте разберем это определение по косточкам:
-
Чистая функция. Это значит, что функция не должна иметь побочных эффектов (например, API-запросов или изменения глобальных переменных). Её результат зависит только от переданных аргументов (state и action). Для одних и тех же аргументов она всегда возвращает один и тот же результат.
-
Принимает state и action. State это текущие данные, с которыми мы работаем. Action это простой объект, который описывает, «что произошло».
-
Возвращает новое состояние. Это, пожалуй, самый важный пункт. Редьюсер никогда не изменяет переданный ему state. Вместо этого он создает и возвращает его полную копию с необходимыми изменениями.
Представьте, что state это документ в государственном архиве. Вы не можете просто взять и исправить в нем строчку. Вы должны создать заверенную копию всего документа, внести в копию правки, а затем архивариус заменит старый документ на новый. Редьюсер это и есть тот самый архивариус, который создает новую, исправленную версию состояния.
Давайте напишем простейший редьюсер для счетчика. Он будет обрабатывать два действия, increment (увеличить на 1) и decrement (уменьшить на 1).
// Начальное состояние (initial state) const initialState = { value: 0 }; // Сам редьюсер function counterReducer(state = initialState, action) { // Проверяем тип действия (action.type) switch (action.type) { case 'counter/increment': return { ...state, value: state.value + 1 }; case 'counter/decrement': return { ...state, value: state.value - 1 }; default: // Если действие неизвестно, возвращаем state без изменений return state; } }
Разберем код:
-
initialStateмы определяем, как выглядит наше состояние при старте приложения. -
counterReducerнаша функция-редьюсер. Она принимаетstate(по умолчанию равенinitialState) иaction. -
switch (action.type)классический способ определить, как реагировать на разные действия. -
case 'counter/increment':если пришло действие с типом'counter/increment', мы возвращаем новый объект. -
{ ...state, value: state.value + 1 }вот здесь происходит вся суть иммутабельности. Оператор spread (...) создает поверхностную копию объектаstate, а затем мы переопределяем свойствоvalue, увеличивая его на 1. Старыйstateпри этом остается нетронутым. -
default: return stateэто обязательное правило. Если редьюсер не знает переданное действие, он должен вернуть state без изменений.
Принципы иммутабельности
Почему так важно не мутировать state, а возвращать его копию? Есть несколько ключевых причин:
-
Предсказуемость. Если состояние изменяется только одним способом (через возврат нового объекта), гораздо проще отследить, где и почему оно изменилось. Это избавляет от багов, когда одна часть приложения неожиданно меняет данные, используемые в другой части.
-
Отладка. Такие инструменты, как Redux DevTools, позволяют «путешествовать во времени» по состоянию вашего приложения. Они записывают каждое действие и соответствующее ему состояние. Если бы вы мутировали state, эти «снимки» состояния были бы просто ссылками на один и тот же объект и вы не смогли бы увидеть разницу. Благодаря иммутабельности, каждый снимок это уникальный объект.
-
Эффективность React.
react-reduxможет очень эффективно определять, изменилось ли состояние и какой компонент нужно перерисовать, используя простое сравнение ссылок (oldState === newState). Если ссылки разные, то состояние изменилось и компонент нужно обновить.
Представьте, что у вас есть вложенный объект:
// ПЛОХО: мутация исходного состояния! state.user.profile.age = 31; // Так делать НЕЛЬЗЯ! // ХОРОШО: создание новой копии! return { ...state, user: { ...state.user, profile: { ...state.user.profile, age: 31 } } };
Как видите, работа с глубоко вложенными объектами «вручную» может быть утомительной. Позже, в Redux Toolkit, мы увидим, как библиотека Immer решает эту проблему, позволяя нам писать код, который выглядит как мутация, но под капотом создает новые иммутабельные объекты.
Создаем стор
Редьюсер описывает, как состояние обновляется. Но где оно хранится? В Store (хранилище).
Store это объект, который:
-
Содержит глобальное состояние приложения.
-
Позволяет получить текущее состояние с помощью
getState(). -
Позволяет обновить состояние, отправив действие с помощью
dispatch(action). -
Позволяет подписаться на изменения с помощью
subscribe(listener).
В «голом» Redux стор создается с помощью функции createStore, в которую мы передаем наш редьюсер.
// Импортируем функцию создания стора из Redux // В реальном проекте: import { createStore } from 'redux'; const { createStore } = Redux; // Передаем наш редьюсер в createStore const store = createStore(counterReducer); // Проверяем начальное состояние console.log('Начальное состояние:', store.getState()); // { value: 0 }
Вуаля! Наш стор создан. Теперь в нем живет наше состояние и мы можем с ним взаимодействовать.
Диспатч экшенов
Состояние в сторе не может измениться само по себе. Единственный способ его изменить, это отправить (задиспатчить) действие.
Action (действие) это простой JavaScript-объект, у которого обязательно должно быть поле type. Обычно type это строка, которая описывает произошедшее событие. По соглашению, типы часто пишут в формате "домен/событие".
Давайте создадим действие для увеличения счетчика и отправим его в стор с помощью метода dispatch().
// Создаем действие. Это просто объект с полем type. const incrementAction = { type: 'counter/increment' }; // Диспатчим (отправляем) это действие в стор store.dispatch(incrementAction); // Смотрим, как изменилось состояние console.log('Состояние после increment:', store.getState()); // { value: 1 } // Диспатчим еще раз store.dispatch(incrementAction); console.log('Состояние после второго increment:', store.getState()); // { value: 2 } // Диспатчим действие уменьшения store.dispatch({ type: 'counter/decrement' }); console.log('Состояние после decrement:', store.getState()); // { value: 1 }
Вот и весь основной цикл данных в Redux!
-
Вызывается
store.dispatch(action). -
Стор запускает редьюсер, передавая ему (редьюсеру) текущее состояние и действие.
-
Редьюсер вычисляет новое состояние и возвращает его.
-
Стор сохраняет новое состояние и уведомляет всех подписчиков об изменении.
Пишем редьюсер для списка задач (todos)
Давайте закрепим материал на более сложном и практическом примере, на управлении списком дел (todos). Нам понадобится обрабатывать добавление и удаление задач.
// Начальное состояние - массив задач const initialTodosState = { items: [ { id: 1, text: 'Изучить React', completed: true }, { id: 2, text: 'Изучить Redux', completed: false } ] }; // Редьюсер для управления списком задач function todosReducer(state = initialTodosState, action) { switch (action.type) { case 'todos/todoAdded': { // action.payload - это новая задача (объект { id, text }) const newTodo = action.payload; // Возвращаем новый объект состояния. // Копируем старый массив items и добавляем в конец новую задачу. return { ...state, items: [...state.items, newTodo] }; } case 'todos/todoToggled': { // action.payload - это id задачи, которую нужно переключить const toggledId = action.payload; // Проходим по всем задачам. Если id совпал, создаем копию задачи и меняем флаг completed. return { ...state, items: state.items.map(todo => { if (todo.id !== toggledId) { return todo; // Возвращаем неизмененную задачу } // Для найденной задачи возвращаем копию с измененным полем completed return { ...todo, completed: !todo.completed }; }) }; } case 'todos/todoDeleted': { // action.payload - это id задачи для удаления const deletedId = action.payload; // Возвращаем новый массив, отфильтровав удаляемую задачу. return { ...state, items: state.items.filter(todo => todo.id !== deletedId) }; } default: return state; } } // Создаем стор для задач const todoStore = createStore(todosReducer); // Проверяем начальное состояние console.log('Начальные задачи:', todoStore.getState().items); // Диспатчим действие добавления новой задачи const newTodo = { id: 3, text: 'Написать первый редьюсер', completed: false }; todoStore.dispatch({ type: 'todos/todoAdded', payload: newTodo }); console.log('После добавления:', todoStore.getState().items); // В массиве items теперь 3 задачи // Диспатчим действие переключения статуса задачи с id=2 todoStore.dispatch({ type: 'todos/todoToggled', payload: 2 }); console.log('После переключения:', todoStore.getState().items); // У задачи с id=2 completed станет true // Диспатчим действие удаления задачи с id=1 todoStore.dispatch({ type: 'todos/todoDeleted', payload: 1 }); console.log('После удаления:', todoStore.getState().items); // В массиве останутся задачи с id=2 и id=3
Обратите внимание на использование action.payload. Это общепринятое поле для передачи данных, необходимых для обновления состояния (например, текст новой задачи или id задачи для удаления).
Тестируем наши редьюсеры в консоли
Вы можете открыть CodeSandbox (https://codesandbox.io/), создать новый проект Vanilla JavaScript и вставить туда код из примеров выше, не забыв добавить тег <script src="https://unpkg.com/redux@4.2.1/dist/redux.min.js"></script> для подключения Redux. Или же вы можете создать файл index.js и запустить его в Node.js, предварительно установив пакет redux (npm install redux).
Практическое задание
-
Создайте редьюсер для управления списком контактов. Он должен обрабатывать:
-
'contacts/addContact'(добавить контакт, payload:{ id, name, phone }) -
'contacts/editContact'(изменить контакт, payload:{ id, name, phone }) -
'contacts/deleteContact'(удалить контакт, payload:id)
-
-
Напишите код, который:
-
Создает стор для этого редьюсера.
-
Выводит в консоль начальное состояние (пустой массив контактов).
-
Диспатчит действие добавления двух контактов.
-
Диспатчит действие изменения одного из контактов.
-
Диспатчит действие удаления контакта.
-
После каждого диспатча выводит в консоль обновленное состояние.
-
Попробуйте выполнить это задание самостоятельно. Это идеально закрепит понимание цикла данных в Redux.
На 3-м этом уроке мы с вами написали довольно много шаблонного кода, ручное создание экшенов, большой switch-case в редьюсере, глубокая копия состояния… Вы могли подумать: «Неужели всё так сложно?»
И вот здесь я хочу вас успокоить и дать главный вывод этого урока, это нужно было только для обучения.
Мы изучили это все, чтобы вы не просто научились использовать инструмент, а поняли его суть. Теперь вы на собственном опыте видите, какие проблемы решает Redux Toolkit (RTK). RTK возьмет на себя всю эту рутину, генерацию экшенов, создание редьюсеров без switch-case, иммутабельные обновления без спред-операторов.
На следующем уроке мы познакомимся с этим инструментом и наконец-то вздохнем с облегчением, увидев, насколько элегантнее и короче может быть код, делающий то же самое.
Если остались вопросы, не стесняйтесь задавть их в комментариях. До встречи на следующем уроке, где мы начнем использовать Redux Toolkit.
Полный курс с уроками по Redux и Redux Toolkit для начинающих
Поддержка автора осуществляется с помощью специальной формы ниже, предоставленной сервисом «ЮMoney». Все платёжные операции выполняются на защищённой странице сервиса, что обеспечивает их корректность и полную безопасность.


