React и Redux вместе. Часть 4 из 7
09.09.2022
Теги: Frontend • Hook • JavaScript • React.js • Web-разработка • Состояние • Список • Теория • Функция
Будем создавать приложение списка задач — почти такое же, как в первой части — но уже с использованием Redux. Есть несколько вариантов использования Redux в React-приложении. Пакет redux
позволяет работать с классическим Redux. Пакет @reduxjs/toolkit
позволяет работать с Redux по-новому. Пакет react-redux
обеспечивает взаимодействие компонентов с хранилищем. В четвертой части будем использовать Redux Toolkit и два хука из пакета react-redux
.
2. Библиотека Redux Toolkit
Использование библиотеки Redux требует большого количества однотипного кода. Библиотека Redux Toolkit призвана исправить этот недостаток. Как и раньше, надо обернуть компонент верхнего уровня в <Provider>
, чтобы все его потомки могли получить доступ к хранилищу. Для взаимодействия React компонентов с хранилищем Redux будем использовать только хуки.
import { Provider } from 'react-redux'; import { store } from './redux/store.js'; function App() { return ( <Provider store={store}> <h1>Список задач</h1> <TodoList /> </Provider> ); }
Как это работает
Наиболее значимыми функциями, предоставляемыми библиотекой Redux Toolkit являются:
configureStore
— функция, предназначенная упростить процесс создания и настройки хранилищаcreateReducer
— функция, помогающая лаконично и понятно описать и создать редюсерcreateAction
— возвращает функцию создателя действия для заданной строки типа действияcreateSlice
— объединяет в себе функционалcreateAction
иcreateReducer
createSelector
— переэкспортированная функция для кэширования из пакетаreselect
Функция configureStore
Если мы не используем пакет @reduxjs/toolkit
, наш код создания хранилища выглядит примерно так:
import { createStore, applyMiddleware, combineReducers, compose } from 'redux'; import thunk from 'redux-thunk'; import logger from 'redux-logger'; import { todoReducer } from './todoReducer.js'; import { userReducer } from './userReducer.js'; const reduvDevExt = window.__REDUX_DEVTOOLS_EXTENSION__; const reduxDevTool = reduvDevExt && process.env.NODE_ENV !== 'production' ? reduvDevExt() : f => f; const store = createStore( combineReducers({ todo: todoReducer, user: userReducer, }), compose( applyMiddleware(thunk, logger), reduxDevTool ) );
Функция configureStore
позволяет комбинировать редюсеры, добавлять middleware, а также использовать расширение Redux DevTools. В качестве входного параметра принимает объект со следующими свойствами:
reducer
— набор пользовательских редюсеровmiddleware
— массив middleware (опционально)devTools
— включить расширение Redux DevTools (по умолчанию —true
)preloadedState
— начальное состояние хранилища (опционально)enhancers
— набор усилителей (опционально)
import { configureStore } from '@reduxjs/toolkit'; import thunk from 'redux-thunk'; import logger from 'redux-logger'; import { todoReducer } from './todoReducer.js'; import { userReducer } from './userReducer.js'; export const store = configureStore({ reducer: { // комбинируем редюсеры todo: todoReducer, user: userReducer, }, middleware: [thunk, logger], // добавляем middleware devTools: process.env.NODE_ENV !== 'production', // расширение Redux DevTools });
Для получения списка middleware по умолчанию можно воспользоваться специальной функцией getDefaultMiddleware
. Перечень этих middleware отличается в зависимости от того, в каком режиме выполняется код. В production
режиме массив состоит только из одного элемента thunk
. В development
режиме список пополняется следующими middleware:
serializableStateInvariant
— предназначен для проверки состояния на предмет несериализуемых значенийimmutableStateInvariant
— предназначен для обнаружения мутаций данных, содержащихся в хранилище
const store = configureStore({ reducer: rootReducer, // добавить к дефолтным middleware еще один middleware logger middleware: getDefaultMiddleware().concat(logger), })
Функция createAction
При использовании библиотеки Redux мы должны объявить константы типов действий, создать генераторы действий (action creator) — и потом использовать функцию store.dispatch
для отправки действий. Генераторы действий нужны для удобства, чтобы не работать напрямую с объектом action
, который содержит обязательное поле type
и необязетельное поле payload
.
export const TODO_CREATE = 'TODO_CREATE'; export const TODO_TOGGLE = 'TODO_TOGGLE'; export const TODO_REMOVE = 'TODO_REMOVE';
export function todoCreate(data) { return { type: TODO_CREATE, payload: data, }; } export function todoToggle(id) { return { type: TODO_TOGGLE, payload: id, }; } export function todoRemove(id) { return { type: TODO_REMOVE, payload: id, }; }
// создание новой задачи store.dispatch(todoCreate(data)); // изменение статуса задачи store.dispatch(todoToggle(id)); // удаление одной задачи store.dispatch(todoRemove(id));
Функция createAction
позволяет два действия — объявление константы и создание генератора действия — объединить в одно.
export const todoCreate = createAction('TODO_CREATE'); export const todoToggle = createAction('TODO_TOGGLE'); export const todoRemove = createAction('TODO_REMOVE');
На вход createAction
принимает тип действия и возвращает генератор действия для этого типа. Генератор действия может быть вызван либо без аргументов, либо с некоторым аргументом (полезная нагрузка), значение которого будет помещено в поле payload
созданного действия. У созданных функцией createAction
генераторов действий переопределен метод toString
, так что тип действия становится их строковым представлением.
// исходный код функции createAction function createAction(type, prepareAction) { function actionCreator(...args) { if (prepareAction) { let prepared = prepareAction(...args); if (!prepared) { throw new Error('prepareAction did not return an object'); } return { type: type, payload: prepared.payload, }; } return { type: type, payload: args[0], }; } actionCreator.toString = () => `${type}`; actionCreator.type = type; actionCreator.match = (action) => action.type === type; return actionCreator; }
Функция createAction
может принимать второй аргумент prepareAction
. Это функция, которая позволяет подготовить payload
, прежде чем он будет использован в редюсере. Например, при создании элемента списка задач, мы получаем только название задачи — а нам нужен объект с полями id
, title
и completed
.
const todoCreate = createAction('TODO_CREATE', function prepare(text) { return { payload: { title: text, id: uuid(), completed: false, }, } }); console.log(todoCreate('Новая задача'));
{ type: 'TODO_CREATE', payload: { title: 'Новая задача', id: '8f3a31ea-53f8-49f7-8a9b-ee6549c2dba1', completed: false, } }
Функция createReducer
Редюсеры часто реализуются с помощью оператора switch
и операторами case
для каждого типа действия. Этот подход работает хорошо, но не лишен бойлерплейта и подвержен ошибкам. Например, легко забыть описать случай default
или не установить начальное состояние.
const initState = []; function todoReducer(state = initState, action) { let newState; switch (action.type) { case TODO_CREATE: newState = [...state, action.payload]; return newState; case TODO_TOGGLE: newState = state.map(todo => { return todo.id === action.payload ? { ...todo, completed: !todo.completed } : todo; }); return newState; case TODO_REMOVE: newState = state.filter(todo => todo.id !== action.payload); return newState; default: return state; } }
Функция createReducer
упрощает создание функции редюсера, определяя их как таблицы поиска мини-функций для обработки каждого типа действия. Она также позволяет существенно упростить логику иммутабельного обновления, написав код в «мутабельном» стиле внутри мини-редюсеров.
const todoCreate = createAction('TODO_CREATE'); const todoToggle = createAction('TODO_TOGGLE'); const todoRemove = createAction('TODO_REMOVE'); const initState = []; const todoReducer = createReducer(initState, (builder) => { builder .addCase(todoCreate, (state, action) => { // todoCreate.toString() === 'TODO_CREATE' state.push(action.payload); // мутация state }) .addCase(todoToggle, (state, action) => { // todoToggle.toString() === 'TODO_TOGGLE' const item = state.find((item) => item.id === action.payload); item.completed = !item.completed; // мутация state }) .addCase(todoRemove, (state, action) => { // todoRemove.toString() === 'TODO_REMOVE' return state.filter((item) => item.id !== action.payload); // возврат нового state }) });
«Мутабельный» стиль обработки событий доступен благодаря использованию библиотеки Immer
. Функция обработчик может либо «мутировать» переданный state
, либо возвращать новый state
. Но, благодаря Immer
, реальная мутация объекта не осуществляется.
Функция createSlice
Функция createSlice
анализирует функции, определенные в поле reducers
, и создает функцию-редюсер и генератор действия для каждого случая. Поскольку в этом случае редюсеры и генераторы действия неразрывно связаны друг с другом, предусмотрено поле extraReducers
, которое позволяет реагировать на action
(как правило, из другого среза), но не создает генератор действия.
В качестве входных параметров принимает объект со следующими полями:
name
— имя среза (служит префиксом для экшенов, напримерtodo/toggle
)initialState
— начальное состояние среза состоянияreducers
— объект с обработчиками экшенов. Каждый обработчик принимаетstate
иaction = {type, payload}
extraReducers
— объект, содержащий редюсеры другого среза, если нужно обновить другой срез
Результатом работы функции является объект, называемый «срез», со следующими полями:
name
— имя среза состоянияreducer
— атоматически созданная функция-редюсер, которую можно передать вcombineReducers
actions
— автоматически созданные с помощьюcreateAction
генераторы действийcaseReducers
— те функции, которые мы передали вcreateSlice
через полеreducers
const todoSlice = createSlice({ name: 'todo', initialState: [], reducers: { create(state, action) { // action = { type: 'todo/create', payload: {id: 123, title: 'Новая задача', completed: false} } state.push(action.payload); // мутация state }, toggle(state, action) { // action = { type: 'todo/toggle', payload: 123 } const item = state.find((item) => item.id === action.payload); item.completed = !item.completed; // мутация state }, remove(state, action) { // action = { type: 'todo/remove', payload: 456 } return state.filter((item) => item.id !== action.payload); // возврат нового state }, }, }); export const { create, toggle, remove } = todoSlice.actions; // генераторы действий export default todoSlice.reducer; // редюсер этого среза
import todoReducer from './todoSlice.js'; import userReducer from './userSlice.js'; export const store = configureStore({ reducer: { todo: todoReducer, user: userReducer, }, });
Если нужно предварительно подготовить payload
, прежде чем использовать:
const todoSlice = createSlice({ name: 'todo', initialState: initialState, reducers: { create: { reducer: (state, action) => { state.push(action.payload); }, prepare: (text) => { return { payload: { id: uuid(), title: text, completed: false, } }; }, }, toggle(state, action) { const item = state.find((item) => item.id === action.payload); item.completed = !item.completed; // мутация state }, remove(state, action) { return state.filter((item) => item.id !== action.payload); // возврат нового state }, }, });
Extra reducers
Разделение данных по слайсам приводит к ситуациям, когда на одно действие нужно реагировать в разных частях хранилища. Например, если удаляется пост, то нужно удалить и его комментарии, которые находятся в другом слайсе.
В Redux такая задача решается просто добавлением в switch
реакции на нужное действие по его имени. В Redux Toolkit так уже не получится из-за железной связи редюсеров с действиями. Это цена, которую мы платим за сокращение кода.
Для реакции на действия, происходящие в других слайсах, Redux Toolkit добавляет механизм дополнительных редюсеров extraReducers
. В слайс добавляется свойство extraReducers
, через которое можно устанавливать реакцию (редюсеры) на внешние действия.
// Импортируем из других слайсов действия, на которые нужно реагировать import { removePost } from './postSlice.js'; const commentSlice = createSlice({ name: 'comment', initialState: [], reducers: { // обычные редюсеры }, extraReducers: (builder) => { // При удалении поста нужно удалить все его комментарии builder.addCase(removePost, (state, action) => { const postId = action.payload; return state.filter((item) => item.postId !== postId); }); }, });
// где-то в приложении... dispatch(removePost(post.id));
Приложение, первый вариант
Нам потребуется развернуть React приложение с помощью create-react-app
+ установить два пакета — react-redux
и @reduxjs/toolkit
.
$ cd react-redux/react-redux-toolkit $ npx create-react-app . $ npm install react-redux @reduxjs/toolkit --save-prod $ npm install uuid re-reselect --save-prod # доп.пакеты
Создаем хранилище
Для начала нам нужно хранилище, для этого создаем директорию src/redux
, внутри нее — все необходимое для работы Redux.
Файл src/redux/store.js
:
import { configureStore } from '@reduxjs/toolkit'; import { todoReducer } from './todoReducer.js'; import { userReducer } from './userReducer.js'; export const store = configureStore({ reducer: { todo: todoReducer, user: userReducer, }, devTools: process.env.NODE_ENV !== 'production', });
Файл src/redux/todoReducer.js
:
import { createReducer } from '@reduxjs/toolkit'; import { todoCreate, todoToggle, todoRemove } from './todoActions.js'; const initState = [ { id: 1, title: 'Первая задача', completed: false }, { id: 2, title: 'Вторая задача', completed: true }, { id: 3, title: 'Третья задача', completed: false }, { id: 4, title: 'Четвертая задача', completed: true }, { id: 5, title: 'Пятая задача', completed: false }, ]; export const todoReducer = createReducer(initState, (builder) => { builder .addCase(todoCreate, (state, action) => { // todoCreate.toString() === 'TODO_CREATE' state.push(action.payload); // мутация state }) .addCase(todoToggle, (state, action) => { // todoToggle.toString() === 'TODO_TOGGLE' const item = state.find((item) => item.id === action.payload); item.completed = !item.completed; // мутация state }) .addCase(todoRemove, (state, action) => { // todoRemove.toString() === 'TODO_REMOVE' return state.filter((item) => item.id !== action.payload); // возврат нового state }); });
Файл src/redux/userReducer.js
:
import { createReducer } from '@reduxjs/toolkit'; import { userLogin, userLogout } from './userActions.js'; const initState = { auth: false, }; export const userReducer = createReducer(initState, (builder) => { builder .addCase(userLogin, (state, action) => { // userLogin.toString() === 'USER_LOGIN' state.auth = true; }) .addCase(userLogout, (state, action) => { // userLogout.toString() === 'USER_LOGOUT' state.auth = false; }); });
Файл src/redux/actions.js
:
import { todoCreate, todoToggle, todoRemove } from './todoActions.js'; import { userLogin, userLogout } from './userActions.js'; export const actions = { todo: { create: todoCreate, toggle: todoToggle, remove: todoRemove, }, user: { login: userLogin, logout: userLogout, }, };
Файл src/redux/todoActions.js
:
import { createAction } from '@reduxjs/toolkit'; export const todoCreate = createAction('TODO_CREATE'); export const todoToggle = createAction('TODO_TOGGLE'); export const todoRemove = createAction('TODO_REMOVE');
Файл src/redux/userActions.js
:
import { createAction } from '@reduxjs/toolkit'; export const userLogin = createAction('USER_LOGIN'); export const userLogout = createAction('USER_LOGOUT');
Файл src/redux/selectors.js
:
import { all, allLength, completed, completedLength, uncompleted, uncompletedLength, findById, ids, } from './todoSelectors.js'; import { auth } from './userSelectors.js'; export const selectors = { todo: { all: all, allLength: allLength, completed: completed, completedLength: completedLength, uncompleted: uncompleted, uncompletedLength: uncompletedLength, findById: findById, ids: ids, }, user: { auth: auth, }, };
Файл src/redux/todoSelectors.js
:
// функцию createSelector теперь можно импортировать из @reduxjs/toolkit import { createSelector } from '@reduxjs/toolkit'; import { createCachedSelector } from 're-reselect'; export const all = (state) => state.todo; // массив всех задач export const allLength = createSelector( // количество всех задач all, (items) => items.length ); export const completed = createSelector( // массив завершенных задач all, (items) => items.filter((item) => item.completed) ); export const completedLength = createSelector( // количество завершенных задач completed, (items) => items.length ); export const uncompleted = createSelector( // массив не завершенных задач all, (items) => items.filter((item) => !item.completed) ); export const uncompletedLength = createSelector( // количество не завершенных задач uncompleted, (items) => items.length ); export const ids = createSelector( // идентификаторы всех задач all, (items) => items.map((item) => item.id) ); export const findById = createCachedSelector( // поиск задачи по идентификатору all, (state, id) => id, (items, id) => items.find((item) => item.id === id) )( (state, id) => id );
Файл src/redux/userSelectors.js
:
export const auth = (state) => state.user.auth;
В предыдущей версии приложения в директории src/redux
у нас было тринадцать файлов, а в этой версии получилось девять. Количество файлов уменьшилось на 30%, а мы еще не использовали функцию createSlice
, которая заменяет createAction
и createReducer
.
Создаем компоненты
Содержимое директории src/component
будет как и в предыдущей версии приложения, так что просто копируем из react-redux-hooks-five
.
Исходные коды
Исходные коды здесь, директория react-redux-toolkit-one
.
$ cd react-redux/react-redux-toolkit-one $ npm install
Приложение, второй вариант
Нам потребуется развернуть React приложение с помощью create-react-app
+ установить два пакета — react-redux
и @reduxjs/toolkit
.
$ cd react-redux/react-redux-toolkit $ npx create-react-app . $ npm install react-redux @reduxjs/toolkit --save-prod $ npm install uuid re-reselect --save-prod # доп.пакеты
Создаем хранилище
Для начала нам нужно хранилище, для этого создаем директорию src/redux
, внутри нее — все необходимое для работы Redux.
Файл src/redux/store.js
:
import { configureStore } from '@reduxjs/toolkit'; import todoReducer from './todoSlice.js'; import userReducer from './userSlice.js'; export const store = configureStore({ reducer: { todo: todoReducer, user: userReducer, }, });
Файл src/redux/todoSlice.js
:
import { createSlice } from '@reduxjs/toolkit'; const initialState = [ { id: 1, title: 'Первая задача', completed: false }, { id: 2, title: 'Вторая задача', completed: true }, { id: 3, title: 'Третья задача', completed: false }, { id: 4, title: 'Четвертая задача', completed: true }, { id: 5, title: 'Пятая задача', completed: false }, ]; const todoSlice = createSlice({ name: 'todo', initialState: initialState, reducers: { create(state, action) { state.push(action.payload); // мутация state }, toggle(state, action) { const item = state.find((item) => item.id === action.payload); item.completed = !item.completed; // мутация state }, remove(state, action) { return state.filter((item) => item.id !== action.payload); // возврат нового state }, }, }); export const { create, toggle, remove } = todoSlice.actions; // генераторы действий export default todoSlice.reducer;
Файл src/redux/userSlice.js
:
import { createSlice } from '@reduxjs/toolkit'; const initialState = { auth: false, }; const userSlice = createSlice({ name: 'user', initialState: initialState, reducers: { login(state, action) { state.auth = true; }, logout(state, action) { state.auth = false; }, }, }); export const { login, logout } = userSlice.actions; // генераторы действий export default userSlice.reducer;
Файл src/redux/actions.js
:
import { create as todoCreate, toggle as todoToggle, remove as todoRemove } from './todoSlice.js'; import { login as userLogin, logout as userLogout } from './userSlice.js'; export const actions = { todo: { create: todoCreate, toggle: todoToggle, remove: todoRemove, }, user: { login: userLogin, logout: userLogout, }, };
Файл src/redux/selectors.js
:
import { all, allLength, completed, completedLength, uncompleted, uncompletedLength, findById, ids, } from './todoSelectors.js'; import { auth } from './userSelectors.js'; export const selectors = { todo: { all: all, allLength: allLength, completed: completed, completedLength: completedLength, uncompleted: uncompleted, uncompletedLength: uncompletedLength, findById: findById, ids: ids, }, user: { auth: auth, }, };
Файл src/redux/todoSelectors.js
:
// функцию createSelector теперь можно импортировать из @reduxjs/toolkit import { createSelector } from '@reduxjs/toolkit'; import { createCachedSelector } from 're-reselect'; export const all = (state) => state.todo; // массив всех задач export const allLength = createSelector( // количество всех задач all, (items) => items.length ); export const completed = createSelector( // массив завершенных задач all, (items) => items.filter((item) => item.completed) ); export const completedLength = createSelector( // количество завершенных задач completed, (items) => items.length ); export const uncompleted = createSelector( // массив не завершенных задач all, (items) => items.filter((item) => !item.completed) ); export const uncompletedLength = createSelector( // количество не завершенных задач uncompleted, (items) => items.length ); export const ids = createSelector( // идентификаторы всех задач all, (items) => items.map((item) => item.id) ); export const findById = createCachedSelector( // поиск задачи по идентификатору all, (state, id) => id, (items, id) => items.find((item) => item.id === id) )( (state, id) => id );
Файл src/redux/userSelectors.js
:
export const auth = (state) => state.user.auth;
У нас получилось семь файлов в директории src/redux
, что почти на 50% меньше, чем при использовании классического Redux.
Создаем компоненты
Содержимое директории src/component
будет как и в предыдущей версии приложения, так что просто копируем из react-redux-toolkit-one
.
Исходные коды
Исходные коды здесь, директория react-redux-toolkit-two
.
$ cd react-redux/react-redux-toolkit-two $ npm install
Поиск: Frontend • Hook • JavaScript • React.js • Web-разработка • Состояние • Список • Теория • Функция