Redux Toolkit
Redux Toolkit - это набор утилит, облегчающих работу с Redux - инструментом для управления состоянием приложений.
Он был разработан для решения трех главных проблем:
- Слишком сложная настройка хранилища (store)
- Для того, чтобы заставить
Redux
делать что-то полезное, приходится использовать дополнительные пакеты - Слишком много шаблонного кода (boilerplate)
Redux Toolkit
предоставляет инструменты для настройки хранилища и выполнения наиболее распространенных операций, а также содержит полезные утилиты, позволяющие упростить код.
Установка
Создание нового приложения с помощью Create React App
# app-name - это название приложения
yarn create react-app app-name --template redux
# или
npx create-react-app app-name --template redux
TypeScript
yarn create react-app app-name --template redux-typescript
# или
npx create-react-app app-name --template redux-typescript
Добавление пакета в существующее приложение
yarn add @reduxjs/toolkit
# или
npm i @reduxjs/toolkit
Состав пакета
Redux Toolkit
включает в себя следующие API:
configureStore()
: обертка дляcreateStore()
, упрощающая настройку хранилища с настройками по умолчанию. Позволяет автоматически комбинировать отдельные частичные редукторы (slice reducers), добавлять промежуточные слои или посредников (middlewares), по умолчанию включаетredux-thunk
(преобразователя), позволяет использовать расширениеRedux DevTools
(инструменты разработчикаRedux
)createReducer()
: позволяет использовать таблицу поиска (lookup table) операций для редукторов случая (case reducers) вместо инструкцийswitch
. В данном API используется библиотекаimmer
, позволяющая напрямую изменять иммутабельный код, например, так:state.todos[3].completed = true
createAction()
: генерирует создателя операции (action creator) для переданного типа операции. Функция имеет переопределенный методtoString()
, что позволяет использовать ее вместо константы типаcreateSlice()
: принимает объект, содержащий редуктор, название части состояния (state slice), начальное значение состояния, и автоматически генерирует частичный редуктор с соответствующими создателями и типами операцииcreateAsyncThunk()
: принимает тип операции и функцию, возвращающую промис, и генерируетthunk
, отправляющий типы операцииpending/fulfilled/rejected
на основе промисаcreateEntityAdapter()
: генерирует набор переиспользуемых редукторов и селекторов для управления нормализованными данными в хранилище- утилита
createSelector()
из библиотекиReselect
Руководство по использованию
Настройка хранилища (store setup)
Разработка любого Redux
-приложения предполагает создание и настройку хранилища. Как правило, данный процесс состоит из следующих этапов:
- Импорт или создание корневого редуктора (root reducer)
- Настройка
middleware
, как минимум, для работы с асинхронным кодом - Настройка инструментов разработчика
Redux
- Возможно, программное изменение кода в зависимости от режима разработки
Ручная настройка
Приведенный ниже пример демострирует типичный процесс настройки хранилища:
import { applyMiddleware, createStore } from 'redux'
import { composeWithDevTools } from 'redux-devtools-extension'
import thunkMiddleware from 'redux-thunk'
import monitorReducersEnhancer from './enhancers/monitorReducers'
import loggerMiddleware from './middleware/logger'
import rootReducer from './reducers'
export default function configureStore(preloadedState) {
const middlewares = [thunkMiddleware, loggerMiddleware]
const middlewareEnhacer = applyMiddleware(...middlewares)
const enhancers = [monitorReducersEnhancer, middlewareEnhacer]
const composedEnhancers = composeWithDevTools(...enhancers)
const store = createStore(rootReducer, preloadedState, composedEnhancers)
if (process.env.NODE_ENV !== 'production' && module.hot) {
module.hot.accept('./reducers', () => store.replaceReducer(rootReducer))
}
return store
}
Проблемы данного подхода:
- Аргументы
(rootReducer, preloadedState, enhancer)
должны быть переданы функцииcreateStore()
в правильном порядке - Процесс настройки
middlewares
иenhancers
(усилителей) может быть сложным, особенно, при попытке добавить несколько частей конфигурации - Документация
Redux DevTools Extension
рекомендует использовать некоторый код для проверки глобального пространства имен для опеределения доступности расширения. Копирование/вставка предложенного сниппета усложняет последующее изучение кода
Упрощение настройки с помощью configureStore()
configureStore()
помогает решить названные проблемы следующим образом:
- Принимает объект с "именованными" параметрами, что облегчает изучение кода
- Позволяет передавать массив
middlewares
иenhancers
, автоматически вызываяapplyMiddleware()
иcompose()
- автоматически включает расширение
Redux DevTools
Кроме того, configureStore()
автоматически добавляет следующих посредников:
redux-thunk
- наиболее часто используемый промежуточный слой для работы с синхронной и асинхронной логикой за пределами компонентов- в режиме разработки, промежуточный слой для обнаружения распространенных ошибок, вроде мутирования состояния или использования несериализуемых значений
Простейшим способом создания и настройки хранилища является передача в configureStore()
корневого редуктора в качестве аргумента reducer
:
import { configureStore } from '@reduxjs/toolkit'
import rootReducer from './reducers'
const store = configureStore({
reducer: rootReducer
})
export default store
Также допускается передавать объект с частичными редукторами, в этом случае configureStore()
автоматически вызывает combineReducers()
:
import usersReducer from './usersReducer'
import postsReducer from './postsReducer'
const store = configureStore({
reducer: {
users: usersReducer,
posts: postsReducer
}
})
Обратите внимание, что это работает только для одного уровня вложенности. Если требуются вложенные редукторы, придется вызывать combineReducers()
самостоятельно.
Для кастомизации настройки хранилища, можно передать дополнительные опции. Вот пример "горячей" перезагрузки:
import { configureStore } from '@reduxjs/toolkit'
import monitorReducersEnhancer from './enhancers/monitorReducers'
import loggerMiddleware from './middleware/logger'
import rootReducer from './reducers'
export default function configureAppStore(preloadedState) {
const store = configureStore({
reducer: rootReducer,
middleware: getDefaultMiddleware =>
getDefaultMiddleware().concat(loggerMiddleware),
preloadedState,
enhancers: [monitorReducersEnhancer]
})
if (process.env.NODE_ENV !== 'production' && module.hot) {
module.hot.accept('./reducers', () => store.replaceReducer(rootReducer))
}
return store
}
Создание редукторов
Редукторы - это самая важная часть Redux
. Как правило, редуктор отвечает за:
- Определение характера ответа на основе поля
type
объекта операции - Обновление состояния посредством копирования части состояния и модификации этой копии
Хотя в редукторе можно использовать любую условную логику, наиболее распространенным и простым способом является использование инструкции switch
. Однако, многим не нравится switch
. В документации по Redux
приводится пример создания функции, выступающей в роли поисковой таблицы на основе типов операции.
Другой проблемой, возникающей при написании редукторов, является необходимость "иммутабельного" обновления состояния. JavaScript - это язык, допускающий мутации, ручное обновление вложенных структур - задача не из простых, легко допустить ошибку.
Упрощение создания редукторов с помощью createReducer()
Функция createReducer()
похожа на функцию создания поисковой таблицы из документации по Redux
. В ней используется библиотека immer
, что позволяет писать "мутирующий" код, обновляющий состояние иммутабельно. Это защищает от непреднамеренного мутирования состояния в редукторе.
Любой редуктор, в котором используется инструкция switch
, может быть преобразован с помощью createReducer()
. Каждый case
становится ключом объекта, передаваемого в createReducer()
. Иммутабельные обновления, такие как распаковка объектов или копирование массивов, могут быть преобразованы в "мутации". Но это не обязательно: можно оставить все как есть.
Ниже приводится пример использования createReducer()
. Начнем с типичного редуктора для списка задач, в котором используется инструкция switch
и иммутабельные обновления:
function todosReducer(state = [], action) {
switch (action.type) {
case 'ADD_TODO': {
return state.concat(action.payload)
}
case 'TOGGLE_TODO': {
const { index } = action.payload
return state.map((todo, i) => {
if (i !== index) return todo
return {
...todo,
completed: !todo.completed
}
})
}
case 'REMOVE_TODO': {
return state.filter((todo, i) => i !== action.payload.index)
}
default:
return state
}
}
Обратите внимание, что мы вызываем state.concat()
для получения копии массива с новой задачей, state.map()
также для получения копии массива, и используем оператор spread
для создания копии задачи, подлежащей обновлению.
С помощью createReducer()
мы можем сократить приведенный пример следующим образом:
const todosReducer = createReducer([], builder => {
builder
.addCase('ADD_TODO', (state, action) => {
// "мутируем" массив, вызывая push()
state.push(action.payload)
})
.addCase('TOGGLE_TODO', (state, action) => {
const todo = state[action.payload.index]
// "мутируем" объект, перезаписывая его поле `completed`
todo.completed = !todo.completed
})
.addCase('REMOVE_TODO', (state,action) => {
// мы по-прежнему можем использовать иммутабельную логику обновления состояния
return state.filter((todo,i) => i !== action.payload.index)
})
})
Возможность "мутировать" состояние особенно полезна при необходимости обновления глубоко вложенных свойств. Например, такой код:
case 'UPDATE_VALUE':
return {
...state,
first: {
...state.first,
second: {
...state.first.second,
[action.someId]: {
...state.first.second[action.someId],
fourth: action.someValue
}
}
}
}
Можно упростить так:
updateValue(state, action) {
const { someId, someValue } = action.payload
state.first.second[someId].fourth = someValue
}
Гораздо лучше!
Функция createReducer()
может быть очень полезной, но следует помнить о том, что:
- "Мутирующий" код правильно работает только внут ри
createReducer()
Immer
не позволяет смешивать "мутирование" черновика (draft
) состояния и возвращение нового состояния
Определение создателей операции (action creators)
Redux
рекомендует использовать "создателей операции" для инкапсуляции процесса создания объектов операции. Это не является обязательным.
Большинство создателей операции очень простые. Они принимают некоторые параметры и возвращают объект операции с определенным полем type
и параметрами, необходимыми для выполнения операции. Данные параметры, обычно, помещаются в поле payload
. Типичный создатель операции выглядит так:
function addTodo(text) {
return {
type: 'ADD_TODO',
payload: {text}
}
}
Определение создателей операции с помощью createAction()
Написание создателей операции вручную может быть утомительным. Redux Toolkit
предоставляет функцию createAction()
, которая генерирует создателя операции с указанным типом операции и преобразует переданные аргументы в поле payload
:
const addTodo = createAction('ADD_TODO')
addTodo({ text: 'Buy milk' })
// { type : "ADD_TODO", payload : {text : "Buy milk"}} )
createAction()
также принимает аргумент-колбек prepare
, позволяющий кастомизировать результирующее поле payload
и добавлять поле meta
, при необходимости.
Использование создателей в качестве типов операции (action types)
Для определения того, как должно быть обновлено состояние, редукторы полагаются на тип операции. Обычно, это делается посредством раздельного определения типов и создателей операции. createAction()
позволяет упростить данный процесс.
Во-первых, createAction()
перезаписывает метод toString()
генерируемых создателей. Это означает, что создатель может использовать в качестве ссылки на "тип операции", например, в ключах, передаваемых в builder.addCase()
или объектной нотации createReducer()
.
Во-вторых, тип операции также определяется как поле type
создателя.
const actionCreator = createAction('SOME_ACTION_TYPE')
console.log(actionCreator.toString())
// SOME_ACTION_TYPE
console.log(actionCreator.type)
// SOME_ACTION_TYPE
const reducer = createReducer({}, builder => {
// Здесь будет автоматически вызван `actionCreator.toString()`
// Кроме того, при использовании TypeScript, будет правильно предложен (предположен) тип операции
builder.addCase(actionCreator, (state, action) => {})
// Или вы можете указать поле `type`
// В этому случае, при использовании TypeScript, тип операции предложен не будет
builder.addCase(actionCreator.type, (state, action) => {})
})
Это означает, что нам не нужно создавать отдельную переменную для типа операции или дублировать название и значение типа, например: const SOME_ACTION_TYPE = 'SOME_ACTION_TYPE'
.
К сожалению, неявного приведения к строке не происходит в инструкции switch
. Приходится делать это вручную:
const actionCreator = createAction('SOME_ACTION_TYPE')
const reducer = (state = {}, action) => {
switch (action.type) {
// ошибка
case actionCreator: {
break
}
// правильно
case actionCreator.toString(): {
break
}
// так тоже работает
case actionCreator.type: {
break
}
}
}
При использовании Redux Toolkit
с TypeScript
, принимайте во внимание, что компилятор TypeScript
может не осуществлять неявного преобразования в строку, когда создатель используется как ключ объекта. В этом случае также может потребоваться прямое указание типа создателя (actionCreator as string
) или использование поля type
в качестве ключа объекта.
Создание частей состо яния (slices of state)
В Redux
состояние, обычно, делится на "части", определяемые редукторами, передаваемыми в combineReducers()
:
import { combineReducers } from 'redux'
import usersReducer from './usersReducer'
import postsReducer from './postsReducer'
const rootReducer = combineReducers({
users: usersReducer,
posts: postsReducer
})
В приведенном примере users
и posts
являются "частями". Оба редуктора:
- "Владеют" частью состояния, включая его начальное значение
- Определяют, как состояние обновляется
- Определяют, какие операции приводят к обновлению состояния
Общий подход состоит в определении частичного редуктора в одном файле, а создателей - в другом. Поскольку и редуктор, и создатели используют одни и те же типы операции, эти типы, как правило, определяются в третьем файле и импортируются в другие файлы:
// postsConstants.js
const CREATE_POST = 'CREATE_POST'
const UPDATE_POST = 'UPDATE_POST'
const DELETE_POST = 'DELETE_POST'
// postsActions.js
import { CREATE_POST, UPDATE_POST, DELETE_POST } from './postsConstants'
export const addPost = (id, title) => ({
type: CREATE_POST,
payload: { id, title }
})
// postsReducer.js
import { CREATE_POST, UPDATE_POST, DELETE_POST } from './postsConstants'
const initialState = []
export default (state = initialState, action) => {
switch (action.type) {
case CREATE_POST: {
// реализация
}
default:
return state
}
}
Единственная по-настоящему полезная часть - это редуктор. Что касается других частей, то:
- Мы могли бы указывать типы операции как строки в обоих местах
- Создател и - полезная штука, но они не являются обязательными - компонент может "пропустить" аргумент
mapDispatch
вconnect()
и просто вызватьprops.dispatch({ type: 'CREATE_POST', payload: { id: 123, title: 'Привет, народ!' } })
- Единственная причина создания нескольких файлов заключается в практике разделения кода по принципу того, что он делает
"Утиная" структура файла предлагает размещать логику, связанную с Redux
, для определенной части в одном файле:
// postsDuck.js
const CREATE_POST = 'CREATE_POST'
const UPDATE_POST = 'UPDATE_POST'
const DELETE_POST = 'DELETE_POST'
export const addPost = (id, title) => ({
type: CREATE_POST,
payload: { id, title }
})
const initialState = []
export default (state = initialState, action) => {
switch (action.type) {
case CREATE_POST: {
// реализация
break
}
default:
return state
}
}
Это облегчает задачу, но нам по-прежнему приходится вручную писать типы и создателей операции.
Определение функций в объектах
В современном JS существует несколько способов определения ключей и функций в объекте:
const keyName = 'ADD_TODO4'
const reducerObject = {
// название ключа в кавычках, стрелочная функция для редуктора
'ADD_TODO1': (state, action) => {}
// ключ без кавычек, ключевое слово "function"
ADD_TODO2: function(state, action) {}
// сокращенный вариант
ADD_TODO3(state,action) {}
// вычисляемое свойство
[keyName]: (state, action) => {}
}
Упрощение создания частей состояния с помощью createSlice()
createSlice()
автоматически генерирует типы и создателей операции на основе переданного названия редуктора:
const postsSlice = createSlice({
name: 'posts',
initialState: [],
reducers: {
createPost(state, action) {},
updatePost(state, action) {},
deletePost(state, action) {}
}
})
console.log(postsSlice)
/*
{
name: 'posts',
actions : {
createPost,
updatePost,
deletePost,
},
reducer
}
*/
const { createPost } = postsSlice.actions
console.log(createPost({ id: 123, title: 'Привет, народ!' }))
// { type : 'posts/createPost', payload : { id : 123, title : 'Привет, народ!' } }
createSlice()
анализирует функции, определенные в поле reducers
, создает редуктор для каждого случая и генерирует создателя, использующего название редуктора в качестве типа операции. Таким образом, редуктор createPost
становится типом операции posts/createPost
, а создатель createPost()
возвращает операцию с этим типом.