Redux Toolkit от первоначальной версии до обработки асинхронных данных: рабочая модель понимания
Узнайте, как хранилище Redux Toolkit, его сегменты, редьюсеры на основе Immer, селекторы и createAsyncThunk взаимодействуют друг с другом, на примере счётчика, корзины покупок и списка товаров.
Источники: Redux Toolkit: A Complete Guide from Basics to Production
По мере роста React-приложения общее состояние, такое как информация о пользователе, корзина покупок, предпочтения, фильтры и данные API, распространяется по многим экранам, и передача всего этого через пропсы становится нестабильной. Redux решает эту проблему с помощью центрального хранилища, а Redux Toolkit (RTK) сегодня считается рекомендуемым способом работы с Redux. В этом руководстве мы пройдем путь от простого счетчика до корзины с вычисляемыми итогами и списка, получаемого из API, чтобы вы могли уверенно читать и писать код на RTK.
Сначала убедитесь, действительно ли вам нужно хранилище: одни только пропсы — это не повод для установки Redux.
Основная идея Redux
Redux хранит общее состояние в одном хранилище и изменяет его только с помощью действий, обрабатываемых редюсерами, поэтому каждое обновление предсказуемо. Без него данные передаются по слоям пропсов по одному.
Component
↓
Props
↓
Component
↓
Props
↓
Component
Благодаря магазину компоненты считывают необходимые данные и отправляют действия для запроса изменений.
Redux Store
↑
│
┌─────────┴─────────┐
│ │
Component A Component B
│ │
└──── dispatch ─────┘
Что добавляет Redux Toolkit
RTK объединяет утилиты для распространённых задач Redux.
Redux Toolkit
│
├── configureStore()
├── createSlice()
├── createAsyncThunk()
├── createSelector()
└── RTK Query
В классическом Redux одна функция реализуется во многих файлах.
Action Types
Action Creators
Reducers
Constants
Store
Middleware
createSlice() позволяет описать большую часть таких функций в одном месте.
Как данные проходят через приложение RTK
Компоненты считывают данные с помощью useSelector() и отправляют действия с помощью useDispatch(); редюсеры генерируют новое состояние.
React Application
│
↓
useSelector()
│
↓
Redux Store
↑
│
useDispatch()
↑
│
Actions
│
↓
Reducer
│
↓
New State
Основной цикл:
UI
↓
dispatch(action)
↓
Reducer
↓
State update
↓
Subscribed components
↓
UI re-render
Установка и создание магазина
Необходимо установить два пакета:
npm install @reduxjs/toolkit react-redux
@reduxjs/toolkit содержит API Redux; react-redux связывает их с React.
useSelector()
useDispatch()
Provider
Пример приложения-счётчика организован по функциям.
src/
│
├── app/
│ └── store.js
│
├── features/
│ └── counter/
│ └── counterSlice.js
│
├── App.jsx
└── main.jsx
configureStore() принимает словарь редюсеров для различных частей состояния; каждый ключ становится отдельной ветвью высшего уровня состояния.
import { configureStore } from "@reduxjs/toolkit";
import counterReducer from "../features/counter/counterSlice";
export const store = configureStore({
reducer: {
counter: counterReducer,
},
});
Результативное состояние:
{
counter: {
value: 0
}
}
Slices: одна функция — один файл
Kаждый slice отвечает за одну логическую часть состояния, и в приложениях обычно их несколько.
authSlice
cartSlice
productSlice
userSlice
notificationSlice
Slice счётчика задаёт имя, начальное значение состояния и по одному редюсеру на каждую операцию, затем экспортирует созданные функции генерации действий и редюсеры.
import { createSlice } from "@reduxjs/toolkit";
const initialState = {
value: 0,
};
const counterSlice = createSlice({
name: "counter",
initialState,
reducers: {
increment(state) {
state.value += 1;
},
decrement(state) {
state.value -= 1;
},
incrementByAmount(state, action) {
state.value += action.payload;
},
},
});
export const {
increment,
decrement,
incrementByAmount,
} = counterSlice.actions;
export default counterSlice.reducer;
Один вызов заменяет три ручно написанных фрагмента кода.
Action Types
+
Action Creators
+
Reducer
Что генерирует createSlice
Возьмём минимальный пример slice:
createSlice({
name: "counter",
initialState,
reducers: {
increment(state) {
state.value += 1;
},
},
});
RTK объединяет имя slice и ключ редюсера в тип действия
"counter/increment"
и создаёт функцию генерации действия.
increment()
Таким образом, dispatch(increment()) эквивалентен отправке следующего действия:
dispatch({
type: "counter/increment",
});
Подключение хранилища состояния к React
В файле main.jsx компонент Provider делает хранилище данных доступным для всех компонентов, находящихся ниже него.
import ReactDOM from "react-dom/client";
import { Provider } from "react-redux";
import App from "./App";
import { store } from "./app/store";
ReactDOM.createRoot(
document.getElementById("root")
).render(
<Provider store={store}>
<App />
</Provider>
);
Чтение с использованием useSelector
Функция useSelector() выбирает именно ту часть состояния, которая необходима компоненту.
import { useSelector } from "react-redux";
function Counter() {
const count = useSelector(
state => state.counter.value
);
return <h1>{count}</h1>;
}
Компонент перерисовывается только тогда, когда меняется это значение; см. как React-Redux решает перерисовать компоненты.
Запись с использованием useDispatch
Функция useDispatch() возвращает метод dispatch хранилища данных.
import { useDispatch } from "react-redux";
import { increment } from "./counterSlice";
function Counter() {
const dispatch = useDispatch();
return (
<button onClick={() => dispatch(increment())}>
Increment
</button>
);
}
После этого клик обрабатывается следующим образом:
Button Click
↓
dispatch(increment())
↓
Action
↓
Reducer
↓
State changes
↓
useSelector detects relevant change
↓
Component re-renders
Передача данных с использованием payload
Действия передают данные в поле payload, которое берется из второго аргумента редьюсера:
incrementByAmount(state, action) {
state.value += action.payload;
}
Вызывающий код передает соответствующее значение:
dispatch(incrementByAmount(10));
что приводит к созданию следующего действия:
{
type: "counter/incrementByAmount",
payload: 10
}
У каждого действия есть type, а также опционально payload.
action
│
├── type
└── payload
Почему здесь допускается изменение состояния
Классический вопрос на собеседовании: кажется, что этот редьюсер изменяет состояние, чего Redux не позволяет.
increment(state) {
state.value += 1;
}
RTK выполняет редьюсеры с использованием Immer, который передаёт промежуточный объект-прокси, фиксирует изменения и создаёт новый неизменяемый объект.
Reducer
↓
Immer Proxy
↓
Track changes
↓
Create new immutable state
Таким образом получается читаемый код без нарушения принципа неизменяемости. Необходимо либо изменить промежуточный объект, либо вернуть новое значение, но никогда не делать и то, и другое одновременно; кроме того, не следует изменять состояние таким образом вне редьюсеров RTK.
Часть, отвечающая за корзину покупок
Электронная корзина изначально пуста.
const initialState = {
items: [],
};
Эта часть добавляет, удаляет и очищает элементы; функция removeItem присваивает новый массив, который также принимается Immer.
import { createSlice } from "@reduxjs/toolkit";
const initialState = {
items: [],
};
const cartSlice = createSlice({
name: "cart",
initialState,
reducers: {
addItem(state, action) {
state.items.push(action.payload);
},
removeItem(state, action) {
state.items = state.items.filter(
item => item.id !== action.payload
);
},
clearCart(state) {
state.items = [];
},
},
});
export const {
addItem,
removeItem,
clearCart,
} = cartSlice.actions;
export default cartSlice.reducer;
Добавление элемента
У нас есть объект продукта:
const product = {
id: 101,
name: "Laptop",
price: 60000,
};
Обработчик клика отправляет его:
const dispatch = useDispatch();
const handleAddToCart = () => {
dispatch(addItem(product));
};
и весь продукт становится полезной нагрузкой.
{
type: "cart/addItem",
payload: {
id: 101,
name: "Laptop",
price: 60000
}
}
Затем редьюсер добавляет его в:
state.items
Удаление элемента
Фильтрация по идентификатору для удаления:
removeItem(state, action) {
state.items = state.items.filter(
item => item.id !== action.payload
);
}
Компонент передаёт только идентификатор:
dispatch(removeItem(101));
так что внутри редьюсера:
action.payload === 101
Расчёт значений вместо их хранения
Предположим, что корзина содержит следующие элементы:
[
{
price: 100,
quantity: 2,
},
{
price: 50,
quantity: 3,
}
]
Общая сумма определяется непосредственно:
100 × 2 + 50 × 3
= 200 + 150
= 350
Хотелось бы хранить общую сумму рядом с элементами:
{
items,
total
}
но тогда каждый редьюсер должен синхронизировать оба значения. Лучше вычислять её заново:
const selectCartTotal = state =>
state.cart.items.reduce(
(total, item) =>
total + item.price * item.quantity,
0
);
и читать полученное значение:
const total = useSelector(selectCartTotal);
Храните источник истины; все остальные значения вычисляйте на его основе.
Селекторы как единая точка доступа
Селектор — это функция, которая считывает состояние.
const selectCartItems = state =>
state.cart.items;
Компоненты используют его вместо необходимости знать форму состояния.
const items = useSelector(selectCartItems);
В противном случае этот путь будет повторяться везде:
state.cart.items
С помощью селектора реструктуризация затрагивает только одну функцию.
Хранение в кэше с использованием createSelector
createSelector() хранит полученный результат в кэше до тех пор, пока не изменятся его входные данные.
import { createSelector } from "@reduxjs/toolkit";
const selectCartItems = state =>
state.cart.items;
export const selectCartTotal = createSelector(
[selectCartItems],
items =>
items.reduce(
(total, item) =>
total + item.price * item.quantity,
0
)
);
Логика проста:
Input unchanged
↓
Return cached result
Input changed
↓
Recalculate
Это помогает при выполнении ресурсоемких вычислений, а также когда селектор возвращает новые массивы или объекты, которые иначе приводили бы к перерисовке при каждом обновлении хранилища.
Загрузка данных с помощью createAsyncThunk
Приложения вызывают такие API, как:
GET /api/products
Запрос проходит через состояния idle, loading и success
idle
↓
loading
↓
success
или переходит в состояние error.
idle
↓
loading
↓
error
createAsyncThunk() моделирует это: необходимо указать префикс типа и асинхронную функцию; бросание исключения означает, что запрос провалился.
import {
createAsyncThunk,
createSlice,
} from "@reduxjs/toolkit";
export const fetchProducts = createAsyncThunk(
"products/fetchProducts",
async () => {
const response = await fetch("/api/products");
if (!response.ok) {
throw new Error("Failed to fetch products");
}
return response.json();
}
);
Он генерирует три действия жизненного цикла:
fetchProducts.pending
fetchProducts.fulfilled
fetchProducts.rejected
Обработка жизненного цикла в слайсе
Слайс продуктов отслеживает элементы, статус и ошибки, обновляя их в extraReducers.
const productsSlice = createSlice({
name: "products",
initialState: {
items: [],
status: "idle",
error: null,
},
reducers: {},
extraReducers: builder => {
builder
.addCase(
fetchProducts.pending,
state => {
state.status = "loading";
}
)
.addCase(
fetchProducts.fulfilled,
(state, action) => {
state.status = "success";
state.items = action.payload;
}
)
.addCase(
fetchProducts.rejected,
(state, action) => {
state.status = "failed";
state.error =
action.error.message;
}
);
},
});
reducers против extraReducers
reducers определяют собственные действия слайса вместе с соответствующими функциями их создания.
reducers: {
addItem() {}
}
extraReducers реагирует на действия, определенные в других местах, например, на те, что возвращаются из createAsyncThunk():
pending
fulfilled
rejected
Слайс лишь прослушивает эти действия.
Отправка thunk из компонента
Начните запрос при монтировании компонента:
const dispatch = useDispatch();
useEffect(() => {
dispatch(fetchProducts());
}, [dispatch]);
В режиме строгой проверки эффекты выполняются дважды во время разработки, поэтому ожидайте двух запросов. Затем выберите нужное состояние
const {
items,
status,
error,
} = useSelector(
state => state.products
);
и отрисуйте интерфейс в зависимости от статуса:
if (status === "loading") {
return <p>Loading...</p>;
}
if (status === "failed") {
return <p>{error}</p>;
}
return (
<div>
{items.map(product => (
<div key={product.id}>
{product.name}
</div>
))}
</div>
);
Для более сложных операций загрузки данных RTK Query может заменить эту схему.
Промежуточное программное обеспечение и configureStore
Промежуточное программное обеспечение находится между функцией dispatch() и редьюсером.
dispatch(action)
↓
Middleware
↓
Reducer
↓
New State
Типичные случаи использования:
- Асинхронная логика
- Логирование
- Аналитика
- Процессы аутентификации
- Побочные эффекты
- Перехват действий
configureStore() заменяет ручную настройку, добавляя стандартное промежуточное программное обеспечение (включая thunks) и инструменты Redux DevTools.
const store = configureStore({
reducer: {
auth: authReducer,
cart: cartReducer,
products: productsReducer,
},
});
Основные выводы
- Slice объединяет состояние, редьюсеры и функции создания действий для одной функциональности.
- Immer делает редьюсеры, работающие по принципу мутации, безопасными, но только внутри редьюсеров RTK.
- Храните минимальное количество состояния; остальное получайте с помощью селекторов, мемоизируя их при высоких затратах ресурсов.
createAsyncThunk()вместе сextraReducersмоделируют состояния загрузки, успеха и ошибок.- Стандартные настройки
configureStore()подходят для большинства приложений.