Redux Toolkit від First Slice до асинхронних даних: робоча модель мислення
Дізнайтеся, як разом працюють магазин Redux Toolkit, слайси, редуктори на основі Immer, селектори та createAsyncThunk, використовуючи лічильник, кошик покупок та список продуктів.
Джерела: Redux Toolkit: A Complete Guide from Basics to Production
Коли React-додаток росте, спільний стан, такий як інформація про користувача, кошик, налаштування, фільтри та дані API, поширюється між багатьма екранами, і передача всього цього через props стає крихкою. Redux вирішує цю проблему за допомогою центрального магазину даних, а Redux Toolkit (RTK) — це рекомендований спосіб роботи з Redux сьогодні. Цей посібник проводить вас від простого сегмента для підрахунку кількості до кошика з обчисленими загальними сумами та списку, який отримує дані з API, щоб ви могли впевнено читати та писати код RTK.
Спочатку переконайтеся, чи справді вам потрібен магазин даних: сама передача даних через props не є причиною для встановлення Redux.
Основна ідея Redux
Redux зберігає спільний стан у одному магазині даних та змінює його лише через дії, якими керують редюсери, тож кожне оновлення є передбачуваним. Без нього дані передаються по одному рівню props за раз.
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
}
}
Слайси: одна функція, один файл
Кожен слайс відповідає одній логічній частині стану, і у додатках зазвичай їх кілька.
authSlice
cartSlice
productSlice
userSlice
notificationSlice
Слайс лічильника визначає назву, початковий стан та редуктор для кожної операції, а потім експортує створені функції дій та редуктор.
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
Розглянемо мінімальний слайс:
createSlice({
name: "counter",
initialState,
reducers: {
increment(state) {
state.value += 1;
},
},
});
RTK поєднує назву слайса та ключ редуктора у тип дії
"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]);
У режимі Strict Mode ефекти виконуються двічі під час розробки, тому очікуйте двох запитів. Потім виберіть стан
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()підходять для більшості додатків.