Redux Essentials, часть 2: Структура приложения с Redux Toolkit
Эта страница переведена PageTurner AI (бета). Не одобрена официально проектом. Нашли ошибку? Сообщить о проблеме →
- Структуру типичного приложения на React + Redux Toolkit
- Как просматривать изменения состояния в расширении Redux DevTools
Введение
В Части 1: Обзор Redux и основные понятия мы рассмотрели, почему Redux полезен, термины и концепции, описывающие различные части кода Redux, и как данные проходят через приложение Redux.
Теперь рассмотрим реальный рабочий пример, чтобы увидеть, как эти части сочетаются.
Пример приложения: Счётчик
Пример проекта, который мы рассмотрим, — это небольшое приложение-счётчик, позволяющее увеличивать или уменьшать число при нажатии кнопок. Оно может показаться не очень интересным, но демонстрирует все важные части работающего приложения на React+Redux.
Проект создан с использованием уменьшенной версии официального шаблона Redux Toolkit для Vite. Из коробки он уже настроен со стандартной структурой приложения Redux, использующей Redux Toolkit для создания хранилища Redux и логики, а также React-Redux для связи хранилища Redux с React-компонентами.
Вот живая версия проекта. Вы можете поэкспериментировать с ней, нажимая кнопки в превью приложения справа, и просматривать исходные файлы слева.
Если вы хотите настроить этот проект на своём компьютере, создайте локальную копию этой командой:
npx degit reduxjs/redux-templates/packages/rtk-app-structure-example my-app
Вы также можете создать новый проект, используя полный шаблон Redux Toolkit для Vite:
npx degit reduxjs/redux-templates/packages/vite-template-redux my-app
Использование приложения-счётчика
Приложение-счётчик уже настроено для наблюдения за внутренними процессами во время использования.
Откройте DevTools вашего браузера. Выберите вкладку "Redux" в DevTools и нажмите кнопку "State" на панели инструментов в правом верхнем углу. Вы увидите примерно следующее:

Справа видно, что наше хранилище Redux начинается со значения состояния приложения:
{
counter: {
value: 0
status: 'idle'
}
}
DevTools покажет, как изменяется состояние хранилища во время использования приложения.
Сначала поэкспериментируем с приложением. Нажмите кнопку "+", затем посмотрите на вкладку "Diff" в Redux DevTools:

Здесь видно два важных момента:
-
При нажатии кнопки "+" в хранилище было отправлено действие с типом
"counter/increment" -
При отправке этого действия поле
state.counter.valueизменилось с0на1
Теперь выполните следующие шаги:
-
Снова нажмите кнопку "+". Отображаемое значение должно стать 2.
-
Один раз нажмите кнопку "-". Отображаемое значение должно стать 1.
-
Нажмите кнопку "Add Amount". Отображаемое значение должно стать 3.
-
Измените число "2" в текстовом поле на "3"
-
Нажмите кнопку "Add Async". Вы увидите, как кнопка заполняется индикатором выполнения, и через пару секунд отображаемое значение должно измениться на 6.
Вернитесь в Redux DevTools. Вы должны увидеть пять диспатченных экшенов — по одному на каждое нажатие кнопки. Теперь выберите последнюю запись "counter/incrementByAmount" из списка слева и перейдите на вкладку "Action" справа:

Мы видим, что этот объект экшена выглядел так:
{
type: 'counter/incrementByAmount',
payload: 3
}
Если перейти на вкладку "Diff", можно заметить, что поле state.counter.value изменилось с 3 на 6 в ответ на этот экшен.
Возможность наблюдать за внутренними процессами приложения и изменениями состояния во времени невероятно мощна!
DevTools предлагают дополнительные команды и опции для отладки. Попробуйте открыть вкладку "Trace" в правом верхнем углу. В панели отобразится стек вызовов JavaScript с фрагментами исходного кода, выполнявшимися при получении экшена хранилищем. Особо будет выделена строка, из которой мы диспатчили этот экшен в компоненте <Counter>:

Это упрощает определение участка кода, отправившего конкретный экшен.
Структура приложения
Теперь, зная функционал приложения, разберём его устройство.
Ключевые файлы приложения:
/srcmain.tsx: точка входа приложенияApp.tsx: корневой React-компонент/appstore.ts: создаёт экземпляр Redux-хранилищаhooks.ts: экспортирует предварительно типизированные хуки React-Redux
/features/counterCounter.tsx: React-компонент UI для счётчикаcounterSlice.ts: Redux-логика для счётчика
Начнём с создания Redux-хранилища.
Создание Redux-хранилища
Откройте app/store.ts, который должен выглядеть так:
import type { Action, ThunkAction } from '@reduxjs/toolkit'
import { configureStore } from '@reduxjs/toolkit'
import counterReducer from '@/features/counter/counterSlice'
export const store = configureStore({
reducer: {
counter: counterReducer
}
})
// Infer the type of `store`
export type AppStore = typeof store
export type RootState = ReturnType<AppStore['getState']>
// Infer the `AppDispatch` type from the store itself
export type AppDispatch = AppStore['dispatch']
// Define a reusable type describing thunk functions
export type AppThunk<ThunkReturnType = void> = ThunkAction<
ThunkReturnType,
RootState,
unknown,
Action
>
Redux-хранилище создаётся функцией configureStore из Redux Toolkit. configureStore требует передачи аргумента reducer.
Приложение может состоять из множества функциональных модулей, каждый со своим редюсером. При вызове configureStore мы передаём все редюсеры в виде объекта. Ключи объекта определят структуру финального состояния.
Файл features/counter/counterSlice.ts экспортирует редюсер для логики счётчика как ESM-экспорт по умолчанию. Импортируем его в текущий файл. Будучи экспортом по умолчанию, мы можем использовать любое имя переменной. Здесь мы называем её counterReducer и включаем при создании хранилища. (Такое поведение импорта/экспорта соответствует стандарту ES Modules, а не специфично для Redux.)
Передача объекта {counter: counterReducer} означает:
- Раздел
state.counterв Redux-состоянии - Функция
counterReducerконтролирует, следует ли и как обновлять разделstate.counterпри диспатче экшенов.
Redux позволяет настраивать хранилище плагинами ("middleware" и "enhancers"). configureStore автоматически добавляет middleware для удобства разработки и настраивает интеграцию с Redux DevTools.
Для TypeScript также экспортируем переиспользуемые типы на основе хранилища: RootState и AppDispatch. Их применение рассмотрим позже.
Слайсы в Redux
«Слайс» (slice) — это коллекция логики редюсеров и действий Redux для отдельной функциональности приложения, обычно определяемая в одном файле. Название происходит от разделения корневого объекта состояния Redux на несколько «слайсов» состояния.
Например, в блоговом приложении настройка хранилища может выглядеть так:
import { configureStore } from '@reduxjs/toolkit'
import usersReducer from '../features/users/usersSlice'
import postsReducer from '../features/posts/postsSlice'
import commentsReducer from '../features/comments/commentsSlice'
export const store = configureStore({
reducer: {
users: usersReducer,
posts: postsReducer,
comments: commentsReducer
}
})
В этом примере state.users, state.posts и state.comments представляют собой отдельные «слайсы» состояния Redux. Поскольку usersReducer отвечает за обновление слайса state.users, мы называем его функцией «слайс-редюсера».
Detailed Explanation: Reducers and State Structure
A Redux store needs to have a single "root reducer" function passed in when it's created. So if we have many different slice reducer functions, how do we get a single root reducer instead, and how does this define the contents of the Redux store state?
If we tried calling all of the slice reducers by hand, it might look like this:
function rootReducer(state = {}, action) {
return {
users: usersReducer(state.users, action),
posts: postsReducer(state.posts, action),
comments: commentsReducer(state.comments, action)
}
}
That calls each slice reducer individually, passes in the specific slice of the Redux state, and includes each return value in the final new Redux state object.
Redux has a function called combineReducers that does this for us automatically. It accepts an object full of slice reducers as its argument, and returns a function that calls each slice reducer whenever an action is dispatched. The result from each slice reducer are all combined together into a single object as the final result. We can do the same thing as the previous example using combineReducers:
const rootReducer = combineReducers({
users: usersReducer,
posts: postsReducer,
comments: commentsReducer
})
When we pass an object of slice reducers to configureStore, it passes those to combineReducers for us to generate the root reducer.
As we saw earlier, you can also pass a reducer function directly as the reducer argument:
const store = configureStore({
reducer: rootReducer
})
Создание слайс-редюсеров и действий
Поскольку мы знаем, что функция counterReducer находится в features/counter/counterSlice.ts, давайте рассмотрим содержимое этого файла по частям.
import { createAsyncThunk, createSlice } from '@reduxjs/toolkit'
import type { PayloadAction } from '@reduxjs/toolkit'
// Define the TS type for the counter slice's state
export interface CounterState {
value: number
status: 'idle' | 'loading' | 'failed'
}
// Define the initial value for the slice state
const initialState: CounterState = {
value: 0,
status: 'idle'
}
// Slices contain Redux reducer logic for updating state, and
// generate actions that can be dispatched to trigger those updates.
export const counterSlice = createSlice({
name: 'counter',
initialState,
// The `reducers` field lets us define reducers and generate associated actions
reducers: {
increment: state => {
// Redux Toolkit allows us to write "mutating" logic in reducers. It
// doesn't actually mutate the state because it uses the Immer library,
// which detects changes to a "draft state" and produces a brand new
// immutable state based off those changes
state.value += 1
},
decrement: state => {
state.value -= 1
},
// Use the PayloadAction type to declare the contents of `action.payload`
incrementByAmount: (state, action: PayloadAction<number>) => {
state.value += action.payload
}
}
})
// Export the generated action creators for use in components
export const { increment, decrement, incrementByAmount } = counterSlice.actions
// Export the slice reducer for use in the store configuration
export default counterSlice.reducer
Ранее мы видели, что нажатие разных кнопок в интерфейсе отправляло три разных типа действий Redux:
-
{type: "counter/increment"} -
{type: "counter/decrement"} -
{type: "counter/incrementByAmount"}
Мы знаем, что действия — это простые объекты с полем type, где type — это строка, и обычно у нас есть функции «создатели действий» (action creators), которые создают и возвращают объекты действий. Где же определены эти объекты действий, строки типов и создатели действий?
Мы могли бы писать всё это вручную каждый раз. Но это было бы утомительно. Кроме того, действительно важная часть Redux — это функции редюсеров и их логика расчёта нового состояния.
Redux Toolkit предоставляет функцию createSlice, которая берёт на себя генерацию строк типов действий, функций-создателей действий и объектов действий. Всё, что вам нужно сделать, — это определить имя для этого слайса, написать объект с функциями редюсеров, и она автоматически сгенерирует соответствующий код действий. Строка из опции name используется как первая часть каждого типа действия, а имя ключа каждой функции редюсера — как вторая часть. Таким образом, имя "counter" и функция редюсера "increment" генерируют тип действия {type: "counter/increment"}. (Ведь зачем писать это вручную, если компьютер может сделать это за нас!)
Помимо поля name, createSlice требует передать начальное значение состояния для редюсеров, чтобы при первом вызове уже было state. В данном случае мы предоставляем объект с полем value, которое начинается с 0, и полем status, которое начинается со значения 'idle'.
Мы видим, что здесь есть три функции редюсеров, что соответствует трём разным типам действий, которые отправлялись при нажатии разных кнопок.
createSlice автоматически генерирует создателей действий с теми же именами, что и написанные нами функции редюсеров. Мы можем убедиться в этом, вызвав одного из них и посмотрев, что он возвращает:
console.log(counterSlice.actions.increment())
// {type: "counter/increment"}
Он также генерирует функцию редьюсера для слайса, которая знает, как реагировать на все эти типы действий.
const newState = counterSlice.reducer(
{ value: 10 },
counterSlice.actions.increment()
)
console.log(newState)
// {value: 11}
Она также генерирует функцию слайс-редюсера, которая знает, как реагировать на все эти типы действий:
Правила редюсеров
-
Они должны вычислять новое значение состояния только на основе аргументов
stateиaction -
Им запрещено изменять существующий
state. Вместо этого они должны выполнять неизменяемые обновления, копируя существующийstateи внося изменения в скопированные значения. -
Они должны быть "чистыми" — они не могут выполнять асинхронную логику или другие "побочные эффекты".
-
Они должны быть «чистыми» (pure) — не должны выполнять асинхронную логику или другие «побочные эффекты»
Но почему эти правила важны? Есть несколько причин:
-
Одна из целей Redux — сделать ваш код предсказуемым. Когда результат функции вычисляется только на основе входных аргументов, легче понять, как работает этот код, и протестировать его.
-
С другой стороны, если функция зависит от внешних переменных или ведёт себя случайным образом, вы никогда не сможете предсказать результат её выполнения.
-
Если функция изменяет другие значения, включая свои аргументы, это может неожиданно повлиять на работу приложения. Часто это приводит к ошибкам вроде: "Я обновил состояние, но интерфейс не перерисовывается!"
-
Некоторые возможности Redux DevTools работают корректно только при соблюдении этих правил в редьюсерах
Особенно важно правило "иммутабельных обновлений", которое заслуживает отдельного обсуждения.
Редьюсеры и иммутабельные обновления
Ранее мы обсуждали "мутацию" (изменение существующих объектов/массивов) и "иммутабельность" (трактовку значений как неизменяемых).
В Redux редьюсерам категорически запрещено изменять исходное/текущее состояние!
// ❌ Illegal - by default, this will mutate the state!
state.value = 123
Запрет на мутацию состояния в Redux обусловлен несколькими причинами:
-
Это вызывает ошибки, например, интерфейс не обновляется при изменении значений
-
Усложняет понимание причин и механизмов обновления состояния
-
Затрудняет написание тестов
-
Ломает функциональность "отладки с перемещением во времени"
-
Противоречит философии и паттернам использования Redux
Если нельзя изменять оригиналы, как тогда возвращать обновлённое состояние?
Редьюсеры могут создавать копии исходных значений и изменять эти копии.
// ✅ This is safe, because we made a copy
return {
...state,
value: 123
}
Мы уже видели, как можно писать иммутабельные обновления вручную, используя операторы распространения (spread) JavaScript и другие функции, возвращающие копии оригиналов. Но если вам кажется, что "писать такие обновления вручную сложно запомнить и легко ошибиться"... вы абсолютно правы! :)
Ручное написание иммутабельной логики обновления действительно сложно, и случайная мутация состояния в редьюсерах — самая распространённая ошибка пользователей Redux.
Поэтому функция createSlice из Redux Toolkit упрощает написание иммутабельных обновлений!
createSlice использует библиотеку Immer. Immer применяет специальный инструмент JavaScript — Proxy — для обёртки ваших данных, позволяя писать код, который "мутирует" эти обёрнутые данные. Но Immer отслеживает все изменения и возвращает безопасное иммутабельно обновлённое значение, как если бы вы вручную написали всю логику обновления.
Вместо такого кода:
function handwrittenReducer(state, action) {
return {
...state,
first: {
...state.first,
second: {
...state.first.second,
[action.someId]: {
...state.first.second[action.someId],
fourth: action.someValue
}
}
}
}
}
Вы можете писать так:
function reducerWithImmer(state, action) {
state.first.second[action.someId].fourth = action.someValue
}
Это гораздо удобнее для чтения!
Но запомните важное правило:
"Мутирующую" логику можно писать ТОЛЬКО в createSlice и createReducer из Redux Toolkit, потому что внутри используется Immer! Если вы напишете мутирующую логику без Immer, это ИЗМЕНИТ состояние и вызовет ошибки!
Учитывая это, давайте вернёмся к редьюсерам из слайса счётчика.
export const counterSlice = createSlice({
name: 'counter',
initialState,
// The `reducers` field lets us define reducers and generate associated actions
reducers: {
increment: state => {
// Redux Toolkit allows us to write "mutating" logic in reducers. It
// doesn't actually mutate the state because it uses the Immer library,
// which detects changes to a "draft state" and produces a brand new
// immutable state based off those changes
state.value += 1
},
decrement: state => {
state.value -= 1
},
// Use the PayloadAction type to declare the contents of `action.payload`
incrementByAmount: (state, action: PayloadAction<number>) => {
state.value += action.payload
}
}
})
Мы видим, что редьюсер increment всегда увеличивает state.value на 1. Поскольку Immer отслеживает изменения в черновике state, нам не нужно ничего возвращать. Аналогично decrement уменьшает значение на 1.
В обоих этих редьюсерах нам на самом деле не нужно обращаться к объекту action. Он всё равно будет передан, но поскольку он нам не нужен, мы можем пропустить объявление action как параметра для редьюсеров.
С другой стороны, редьюсеру incrementByAmount нужно знать одну вещь: сколько следует добавить к текущему значению счётчика. Поэтому мы объявляем этот редьюсер с двумя аргументами: state и action. В данном случае мы знаем, что число, введённое в поле "amount", помещается в поле action.payload, поэтому можем добавить его к state.value.
Если мы используем TypeScript, нам нужно указать TS тип для action.payload. Тип PayloadAction объявляет, что "это объект действия, где тип action.payload равен..." тому типу, который вы укажете. Здесь мы знаем, что UI берёт числовую строку из текстового поля "amount", преобразует её в число и пытается диспатчить действие с этим значением, поэтому объявляем тип как action: PayloadAction<number>.
Подробнее о неизменяемости и написании неизменяемых обновлений см. в документации «Паттерны неизменяемых обновлений» и Полном руководстве по неизменяемости в React и Redux.
Подробности об использовании Immer для «мутирующих» неизменяемых обновлений см. в документации Immer и статье «Написание редьюсеров с Immer».
Дополнительная логика Redux
Основу Redux составляют редьюсеры, экшены и стор. Но также часто используются несколько дополнительных типов функций Redux.
Чтение данных с помощью селекторов
Мы можем вызвать store.getState(), чтобы получить весь текущий корневой объект состояния, и обращаться к его полям, например state.counter.value.
Стандартной практикой является написание "селекторных" функций, которые выполняют эти операции за нас. В данном случае counterSlice.ts экспортирует две повторно используемые функции-селекторы:
// Selector functions allows us to select a value from the Redux root state.
// Selectors can also be defined inline in the `useSelector` call
// in a component, or inside the `createSlice.selectors` field.
export const selectCount = (state: RootState) => state.counter.value
export const selectStatus = (state: RootState) => state.counter.status
Функции-селекторы обычно вызываются с передачей всего корневого объекта состояния Redux в качестве аргумента. Они могут извлекать конкретные значения из корневого состояния или выполнять расчёты и возвращать новые значения.
Поскольку мы используем TypeScript, нам также необходимо использовать тип RootState, экспортированный из store.ts, для определения типа аргумента state в каждом селекторе.
Обратите внимание: не нужно создавать отдельные функции-селекторы для каждого поля в каждом слайсе! (Этот пример сделал так, чтобы продемонстрировать идею написания селекторов, но у нас в counterSlice.ts всего два поля). Вместо этого соблюдайте баланс в количестве создаваемых селекторов.
Мы узнаем больше о функциях-селекторах в Части 4: Использование данных Redux и рассмотрим их оптимизацию в Части 6: Производительность.
Подробнее о том, зачем и как использовать функции-селекторы, см. в статье Получение данных с помощью селекторов.
Написание асинхронной логики с санками
До сих пор вся логика в нашем приложении была синхронной. Экшены диспатчатся, стор запускает редьюсеры и вычисляет новое состояние, после чего функция диспатча завершает работу. Но в JavaScript есть множество способов написания асинхронного кода, и в наших приложениях обычно присутствует асинхронная логика, например для получения данных из API. Нам нужно место для размещения такой логики в Redux-приложениях.
Санк — это особый тип функции Redux, которая может содержать асинхронную логику. Санки пишутся с использованием двух функций:
-
Внутренняя функция-санк, которая получает аргументы
dispatchиgetState -
Внешняя создающая функция, которая создаёт и возвращает функцию-санк
Следующая функция, экспортируемая из counterSlice, является примером создателя действия-санка:
// The function below is called a thunk, which can contain both sync and async logic
// that has access to both `dispatch` and `getState`. They can be dispatched like
// a regular action: `dispatch(incrementIfOdd(10))`.
// Here's an example of conditionally dispatching actions based on current state.
export const incrementIfOdd = (amount: number): AppThunk => {
return (dispatch, getState) => {
const currentValue = selectCount(getState())
if (currentValue % 2 === 1) {
dispatch(incrementByAmount(amount))
}
}
}
В этом санке мы используем getState() для получения текущего состояния хранилища и dispatch() для диспетчеризации другого действия. Здесь также легко можно разместить асинхронную логику, например setTimeout или await.
Мы можем использовать их так же, как обычные создатели действий Redux:
store.dispatch(incrementIfOdd(6))
Использование санков требует добавления мидлвэра redux-thunk (плагина для Redux) в хранилище при его создании. К счастью, функция configureStore из Redux Toolkit уже автоматически настраивает это, поэтому мы можем сразу использовать санки.
При написании санков нужно обеспечить правильную типизацию методов dispatch и getState. Мы могли бы определить функцию-санк как (dispatch: AppDispatch, getState: () => RootState), но стандартно определять переиспользуемый тип AppThunk для этого в файле хранилища.
Когда требуется выполнять HTTP-запросы для получения данных с сервера, этот вызов можно поместить в санк. Вот более развёрнутый пример для понимания реализации:
// the outside "thunk creator" function
const fetchUserById = (userId: string): AppThunk => {
// the inside "thunk function"
return async (dispatch, getState) => {
try {
dispatch(userPending())
// make an async call in the thunk
const user = await userAPI.fetchById(userId)
// dispatch an action when we get the response back
dispatch(userLoaded(user))
} catch (err) {
// If something went wrong, handle it here
}
}
}
Redux Toolkit включает метод createAsyncThunk, который автоматизирует всю диспетчеризацию. Следующая функция в counterSlice.ts — асинхронный санк, выполняющий моковый API-запрос со значением счётчика. При диспетчеризации этого санка сначала отправляется действие pending, а после завершения асинхронной логики — действие fulfilled или rejected.
// Thunks are commonly used for async logic like fetching data.
// The `createAsyncThunk` method is used to generate thunks that
// dispatch pending/fulfilled/rejected actions based on a promise.
// In this example, we make a mock async request and return the result.
// The `createSlice.extraReducers` field can handle these actions
// and update the state with the results.
export const incrementAsync = createAsyncThunk(
'counter/fetchCount',
async (amount: number) => {
const response = await fetchCount(amount)
// The value we return becomes the `fulfilled` action payload
return response.data
}
)
При использовании createAsyncThunk его действия обрабатываются в createSlice.extraReducers. В нашем случае мы обрабатываем все три типа действий, обновляем поле status и значение value:
export const counterSlice = createSlice({
name: 'counter',
initialState,
reducers: {
// omit reducers
},
// The `extraReducers` field lets the slice handle actions defined elsewhere,
// including actions generated by createAsyncThunk or in other slices.
extraReducers: builder => {
builder
// Handle the action types defined by the `incrementAsync` thunk defined below.
// This lets the slice reducer update the state with request status and results.
.addCase(incrementAsync.pending, state => {
state.status = 'loading'
})
.addCase(incrementAsync.fulfilled, (state, action) => {
state.status = 'idle'
state.value += action.payload
})
.addCase(incrementAsync.rejected, state => {
state.status = 'failed'
})
}
})
Если вам интересно, почему для асинхронной логики используются санки, вот более глубокое объяснение:
Detailed Explanation: Thunks and Async Logic
We know that we're not allowed to put any kind of async logic in reducers. But, that logic has to live somewhere.
If we had access to the Redux store, we could write some async code and call store.dispatch() when we're done:
const store = configureStore({ reducer: counterReducer })
setTimeout(() => {
store.dispatch(increment())
}, 250)
But, in a real Redux app, we're not allowed to import the store into other files, especially in our React components, because it makes that code harder to test and reuse.
In addition, we often need to write some async logic that we know will be used with some store, eventually, but we don't know which store.
The Redux store can be extended with "middleware", which are a kind of add-on or plugin that can add extra abilities. The most common reason to use middleware is to let you write code that can have async logic, but still talk to the store at the same time. They can also modify the store so that we can call dispatch() and pass in values that are not plain action objects, like functions or Promises.
The Redux Thunk middleware modifies the store to let you pass functions into dispatch. In fact, it's short enough we can paste it here:
const thunkMiddleware =
({ dispatch, getState }) =>
next =>
action => {
if (typeof action === 'function') {
return action(dispatch, getState)
}
return next(action)
}
It looks to see if the "action" that was passed into dispatch is actually a function instead of a plain action object. If it's actually a function, it calls the function, and returns the result. Otherwise, since this must be an action object, it passes the action forward to the store.
This gives us a way to write whatever sync or async code we want, while still having access to dispatch and getState.
Мы рассмотрим использование санков в Часть 5: Асинхронная логика и получение данных
См. документацию по Redux Thunk, статью Что такое санк? и FAQ Redux о "зачем нужен мидлвэр для асинхронных операций?" для дополнительной информации.
React-компонент Counter
Ранее мы видели, как выглядит изолированный React-компонент <Counter>. В нашем React+Redux приложении используется похожий компонент <Counter>, но с некоторыми отличиями.
Начнём с рассмотрения файла компонента Counter.tsx:
import { useState } from 'react'
// Use pre-typed versions of the React-Redux
// `useDispatch` and `useSelector` hooks
import { useAppDispatch, useAppSelector } from '@/app/hooks'
import {
decrement,
increment,
incrementAsync,
incrementByAmount,
incrementIfOdd,
selectCount,
selectStatus
} from './counterSlice'
import styles from './Counter.module.css'
export function Counter() {
const dispatch = useAppDispatch()
const count = useAppSelector(selectCount)
const status = useAppSelector(selectStatus)
const [incrementAmount, setIncrementAmount] = useState('2')
const incrementValue = Number(incrementAmount) || 0
return (
<div>
<div className={styles.row}>
<button
className={styles.button}
aria-label="Decrement value"
onClick={() => {
dispatch(decrement())
}}
>
-
</button>
<span aria-label="Count" className={styles.value}>
{count}
</span>
<button
className={styles.button}
aria-label="Increment value"
onClick={() => {
dispatch(increment())
}}
>
+
</button>
{/* omit additional rendering output here */}
</div>
</div>
)
}
Как и в предыдущем примере на чистом React, у нас есть функциональный компонент Counter, хранящий данные через хук useState.
Однако в нашем компоненте текущее значение счётчика, похоже, не хранится как состояние. Хотя переменная count присутствует, она не извлекается через хук useState.
Помимо встроенных хуков React вроде useState и useEffect, другие библиотеки могут создавать собственные пользовательские хуки, использующие хуки React для реализации кастомной логики.
Библиотека React-Redux предоставляет набор пользовательских хуков для взаимодействия React-компонентов с хранилищем Redux.
Чтение данных через useSelector
Во-первых, хук useSelector позволяет нашему компоненту извлекать любые нужные данные из состояния хранилища Redux.
Ранее мы видели, что можем писать функции-"селекторы", которые принимают state как аргумент и возвращают часть значения состояния. В частности, наш файл counterSlice.ts экспортирует selectCount и selectStatus.
Если бы у нас был доступ к хранилищу Redux, мы могли бы получить текущее значение счётчика так:
const count = selectCount(store.getState())
console.log(count)
// 0
Наши компоненты не могут обращаться к хранилищу Redux напрямую, потому что мы не можем импортировать его в файлы компонентов. Но useSelector берёт на себя взаимодействие с хранилищем Redux за кулисами. Если мы передаём функцию-селектор, он вызывает someSelector(store.getState()) за нас и возвращает результат.
Таким образом, мы можем получить текущее значение счётчика из хранилища следующим образом:
const count = useSelector(selectCount)
Нам не обязательно использовать только уже экспортированные селекторы. Например, мы можем написать функцию-селектор как встроенный аргумент для useSelector:
const countPlusTwo = useSelector((state: RootState) => state.counter.value + 2)
Каждый раз, когда действие отправлено и хранилище Redux обновлено, useSelector перезапустит нашу функцию-селектор. Если селектор вернёт значение, отличное от предыдущего, useSelector гарантирует, что наш компонент перерендерится с новым значением.
Отправка действий с помощью useDispatch
Аналогично, мы знаем, что при доступе к хранилищу Redux мы можем отправлять действия с помощью создателей действий, например store.dispatch(increment()). Поскольку у нас нет доступа к самому хранилищу, нам нужен способ получить доступ именно к методу dispatch.
Хук useDispatch делает это за нас и предоставляет реальный метод dispatch из хранилища Redux:
const dispatch = useDispatch()
Отсюда мы можем отправлять действия, когда пользователь выполняет какое-либо действие, например нажимает на кнопку:
<button
className={styles.button}
aria-label="Increment value"
onClick={() => {
dispatch(increment())
}}
>
+
</button>
Определение предварительно типизированных хуков React-Redux
По умолчанию хук useSelector требует объявлять (state: RootState) для каждой функции-селектора. Мы можем создать предварительно типизированные версии хуков useSelector и useDispatch, чтобы не повторять : RootState каждый раз.
import { useDispatch, useSelector } from 'react-redux'
import type { AppDispatch, RootState } from './store'
// Use throughout your app instead of plain `useDispatch` and `useSelector`
export const useAppDispatch = useDispatch.withTypes<AppDispatch>()
export const useAppSelector = useSelector.withTypes<RootState>()
Затем мы можем импортировать хуки useAppSelector и useAppDispatch в наши компоненты и использовать их вместо оригинальных версий.
Состояние компонента и формы
К этому моменту вы могли задуматься: "Должен ли я всегда помещать всё состояние приложения в хранилище Redux?"
Ответ: НЕТ. Глобальное состояние, необходимое всему приложению, должно находиться в хранилище Redux. Состояние, которое нужно только в одном месте, должно оставаться в состоянии компонента.
В этом примере у нас есть поле ввода, где пользователь может ввести следующее число для добавления к счётчику:
const [incrementAmount, setIncrementAmount] = useState('2')
const incrementValue = Number(incrementAmount) || 0
// later
return (
<div className={styles.row}>
<input
className={styles.textbox}
aria-label="Set increment amount"
value={incrementAmount}
onChange={e => setIncrementAmount(e.target.value)}
/>
<button
className={styles.button}
onClick={() => dispatch(incrementByAmount(incrementValue))}
>
Add Amount
</button>
<button
className={styles.asyncButton}
onClick={() => dispatch(incrementAsync(incrementValue))}
>
Add Async
</button>
</div>
)
Мы могли бы хранить текущую строку с числом в хранилище Redux, отправляя действие в обработчике onChange и сохраняя его в нашем редьюсере. Но это не даёт нам никаких преимуществ. Единственное место, где используется эта строка — здесь, в компоненте <Counter>. (Конечно, в этом примере есть только один другой компонент: <App>. Но даже в большом приложении со множеством компонентов только <Counter> использует это значение ввода.)
Поэтому логично хранить это значение в хуке useState здесь, в компоненте <Counter>.
Аналогично, если бы у нас был булев флаг isDropdownOpen, ни один другой компонент приложения не использовал бы его — он действительно должен оставаться локальным для этого компонента.
В приложении React + Redux ваше глобальное состояние должно находиться в хранилище Redux, а локальное состояние — оставаться в компонентах React.
Если вы не уверены, куда что-то поместить, вот несколько эмпирических правил для определения, какие данные следует помещать в Redux:
-
Важны ли эти данные для других частей приложения?
-
Нужно ли создавать производные данные на основе этих исходных данных?
-
Используются ли эти данные для управления несколькими компонентами?
-
Есть ли ценность в возможности восстановить это состояние на определённый момент времени (например, для отладки с перемещением во времени)?
-
Хотите ли вы кэшировать данные (использовать существующие в состоянии вместо повторных запросов)?
-
Нужно ли сохранять согласованность данных при горячей замене компонентов (которые могут терять внутреннее состояние)?
Это также хороший пример того, как следует подходить к работе с формами в Redux в целом. Состояние большинства форм, вероятно, не должно храниться в Redux. Вместо этого храните данные в компонентах формы во время редактирования, а затем диспатчите действия Redux для обновления хранилища после завершения работы пользователя.
Ещё один важный момент: помните санк incrementAsync из counterSlice.ts? Мы используем его здесь, в этом компоненте. Обратите внимание, что мы применяем его точно так же, как и обычные создатели действий. Для компонента не имеет значения, диспатчим ли мы обычное действие или запускаем асинхронную логику — он знает только, что при клике на кнопку происходит диспатч.
Предоставление хранилища
Мы видели, что компоненты могут использовать хуки useSelector и useDispatch для взаимодействия с хранилищем Redux. Но если мы не импортировали хранилище напрямую, откуда эти хуки знают, к какому именно хранилищу обращаться?
Теперь, когда мы рассмотрели все части приложения, пришло время вернуться к начальной точке и понять, как соединяются последние элементы пазла.
import React from 'react'
import { createRoot } from 'react-dom/client'
import { Provider } from 'react-redux'
import App from './App'
import { store } from './app/store'
import './index.css'
const container = document.getElementById('root')!
const root = createRoot(container)
root.render(
<React.StrictMode>
<Provider store={store}>
<App />
</Provider>
</React.StrictMode>
)
Мы всегда вызываем root.render(<App />), чтобы React начал рендерить корневой компонент <App>. Чтобы хуки вроде useSelector работали корректно, необходимо использовать компонент <Provider>, который в фоновом режиме передаёт хранилище Redux для доступа к нему.
Мы уже создали хранилище в app/store.ts, поэтому можем импортировать его здесь. Затем оборачиваем весь <App> в компонент <Provider> и передаём хранилище: <Provider store={store}>.
Теперь любые React-компоненты, использующие useSelector или useDispatch, будут обращаться именно к тому хранилищу Redux, которое мы передали через <Provider>.
Итоги изученного
Хотя пример с счётчиком довольно прост, он демонстрирует все ключевые элементы React + Redux приложения в работе. Вот что мы рассмотрели:
- Создание хранилища Redux через API
configureStoreиз Redux ToolkitconfigureStoreпринимает функциюreducerкак именованный аргументconfigureStoreавтоматически настраивает хранилище с оптимальными параметрами
- Организация логики Redux в «срезах» (slices)
- «Срез» содержит редюсер и действия для определённой функциональности / раздела состояния Redux
- API
createSliceиз Redux Toolkit генерирует создатели действий и их типы для каждого редюсера
- Правила для редюсеров Redux
- Должны вычислять новое состояние исключительно на основе аргументов
stateиaction - Должны выполнять иммутабельные обновления через копирование существующего состояния
- Не могут содержать асинхронной логики или побочных эффектов
createSliceиспользует Immer для «мутабельного» синтаксиса при иммутабельных обновлениях
- Должны вычислять новое состояние исключительно на основе аргументов
- Чтение значений состояния через «селекторы»
- Селекторы принимают
(state: RootState)и возвращают значение из состояния или производное значение - Селекторы можно писать в файлах срезов или непосредственно в хуке
useSelector
- Селекторы принимают
- Асинхронная логика в «санках» (thunks)
- Санки получают
dispatchиgetStateкак аргументы - Redux Toolkit включает middleware
redux-thunkпо умолчанию
- Санки получают
- Взаимодействие React-компонентов с хранилищем через React-Redux
- Обёртка приложения в
<Provider store={store}>обеспечивает доступ компонентов к хранилищу - Хук
useSelectorпозволяет компонентам читать значения из хранилища Redux - Хук
useDispatchпозволяет диспатчить действия - Для TypeScript создаются предварительно типизированные хуки
useAppSelectorиuseAppDispatch - Глобальное состояние должно находиться в Redux, локальное — оставаться в React-компонентах
- Обёртка приложения в
Что дальше?
Теперь, когда вы увидели все компоненты Redux-приложения в действии, настало время создать собственное! В оставшейся части этого руководства вы будете разрабатывать более крупный пример приложения с использованием Redux. По ходу работы мы рассмотрим все ключевые концепции, необходимые для правильного применения Redux.
Переходите к Части 3: Базовый поток данных в Redux, чтобы начать создание примера приложения.