Redux Toolkit: сучасний стандарт Redux
Redux Toolkit: сучасний стандарт Redux
🎯 Мета теми
- Зрозуміти, як Redux Toolkit усуває boilerplate класичного Redux.
- Опанувати
createSliceдля автоматичної генерації actions та reducers. - Навчитися писати асинхронну логіку через
createAsyncThunk. - Використовувати Immer для «мутабельного» синтаксису без фактичних мутацій.
- Налаштувати типізовані хуки
useAppSelectorтаuseAppDispatch.
🔑 Ключові терміни
- Slice: логічна частина Redux стану з автоматично згенерованими actions та reducer.
- Immer: бібліотека, вбудована у RTK, що дозволяє писати «мутабельний» код, який під капотом створює immutable копії.
- configureStore: замінює
createStore, автоматично підключає Redux DevTools та middleware. - createAsyncThunk: утиліта для асинхронних операцій із автоматичною генерацією
pending/fulfilled/rejectedactions.
Проблема класичного Redux: ocean of boilerplate
У попередній темі ми створили Redux-додаток класичним способом. Для простого лічильника довелося написати:
- Константи типів actions (
const INCREMENT = "counter/increment") - Action creators — функції, що генерують action-об'єкти
- Reducer із
switch/caseта ручним spread-синтаксисом для immutability - Типізацію discriminated unions для кожного action
- CombineReducers для об'єднання reducers
- Middleware через
applyMiddleware
// ❌ Класичний підхід — багато ручної роботи
const INCREMENT = "counter/increment";
const DECREMENT = "counter/decrement";
const INCREMENT_BY_AMOUNT = "counter/incrementByAmount";
type CounterAction =
| { type: typeof INCREMENT }
| { type: typeof DECREMENT }
| { type: typeof INCREMENT_BY_AMOUNT; payload: number };
export const increment = (): CounterAction => ({ type: INCREMENT });
export const decrement = (): CounterAction => ({ type: DECREMENT });
export const incrementByAmount = (amount: number): CounterAction => ({
type: INCREMENT_BY_AMOUNT,
payload: amount,
});
interface CounterState {
value: number;
}
const initialState: CounterState = { value: 0 };
function counterReducer(state = initialState, action: CounterAction): CounterState {
switch (action.type) {
case INCREMENT:
return { ...state, value: state.value + 1 }; // Ручний spread
case DECREMENT:
return { ...state, value: state.value - 1 };
case INCREMENT_BY_AMOUNT:
return { ...state, value: state.value + action.payload };
default:
return state;
}
}
// ✅ Redux Toolkit — усе генерується автоматично
import { createSlice, PayloadAction } from "@reduxjs/toolkit";
interface CounterState {
value: number;
}
const counterSlice = createSlice({
name: "counter",
initialState: { value: 0 } as CounterState,
reducers: {
increment(state) {
state.value += 1; // Виглядає як мутація, але це безпечно!
},
decrement(state) {
state.value -= 1;
},
incrementByAmount(state, action: PayloadAction<number>) {
state.value += action.payload;
},
},
});
// Actions генеруються автоматично: counterSlice.actions.increment()
export const { increment, decrement, incrementByAmount } = counterSlice.actions;
export default counterSlice.reducer;
- Класичний Redux для одного reducer: ~70–100 рядків (actions, types, reducer, immutable updates).
- Redux Toolkit для одного slice: ~15–20 рядків (reducer-методи з «мутабельним» синтаксисом).
Встановлення та налаштування Redux Toolkit
npm install @reduxjs/toolkit react-redux
pnpm add @reduxjs/toolkit react-redux
yarn add @reduxjs/toolkit react-redux
Redux Toolkit включає:
redux(ядро Redux)immer(immutable updates через «мутабельний» синтаксис)redux-thunk(middleware для async logic)reselect(мемоїзовані селектори)
createSlice: автоматична генерація actions та reducer
Slice (зріз) — це логічна частина Redux стану, яка містить початковий стан, reducer-функції та автоматично згенеровані action creators. createSlice замінює всю тріаду actionTypes → actionCreators → reducer.
Базовий приклад: лічильник
// src/store/slices/counterSlice.ts
import { createSlice, PayloadAction } from "@reduxjs/toolkit";
interface CounterState {
value: number;
history: number[];
}
const initialState: CounterState = {
value: 0,
history: [],
};
const counterSlice = createSlice({
name: "counter", // Префікс для типів actions: counter/increment
initialState,
reducers: {
// Кожен метод стає action creator + case у reducer
increment(state) {
state.value += 1; // Immer перетворює це на immutable update
state.history.push(state.value);
},
decrement(state) {
state.value -= 1;
state.history.push(state.value);
},
incrementByAmount(state, action: PayloadAction<number>) {
// PayloadAction<T> типізує action.payload
state.value += action.payload;
state.history.push(state.value);
},
reset(state) {
// Можна повернути новий стан або мутувати поточний
return initialState;
},
},
});
// Експортуємо згенеровані action creators
export const { increment, decrement, incrementByAmount, reset } = counterSlice.actions;
// Експортуємо reducer для підключення до store
export default counterSlice.reducer;
state.value += 1, state.history.push(...)) та записує їх як «рецепт змін». Після завершення функції Immer створює новий immutable об'єкт із застосованими змінами. Оригінальний state залишається незмінним — це pure function, як вимагає Redux.Налаштування store через configureStore
// src/store/index.ts
import { configureStore } from "@reduxjs/toolkit";
import counterReducer from "./slices/counterSlice";
export const store = configureStore({
reducer: {
counter: counterReducer,
},
});
// Виведення типів для useSelector та useDispatch
export type RootState = ReturnType<typeof store.getState>;
export type AppDispatch = typeof store.dispatch;
configureStore автоматично:- Підключає Redux DevTools Extension (не потрібен
window.__REDUX_DEVTOOLS_EXTENSION__). - Додає redux-thunk middleware для асинхронних actions.
- Увімкнує перевірки у development-режимі (детектує мутації стану, не-серіалізовані значення у actions).
Типізовані хуки useAppSelector та useAppDispatch
У класичному Redux доводилося постійно вказувати тип RootState у кожному виклику useSelector. RTK рекомендує створити типізовані хуки один раз для всього застосунку:
// src/store/hooks.ts
import { TypedUseSelectorHook, useDispatch, useSelector } from "react-redux";
import type { RootState, AppDispatch } from "./index";
// Типізовані версії useDispatch та useSelector
export const useAppDispatch = () => useDispatch<AppDispatch>();
export const useAppSelector: TypedUseSelectorHook<RootState> = useSelector;
Тепер у компонентах використовуйте ці хуки замість оригінальних:
// src/components/Counter.tsx
import { useAppDispatch, useAppSelector } from "../store/hooks";
import { increment, decrement, incrementByAmount, reset } from "../store/slices/counterSlice";
function Counter() {
// TypeScript автоматично виводить типи
const { value, history } = useAppSelector((state) => state.counter);
const dispatch = useAppDispatch();
return (
<div className="counter">
<h1>Лічильник: {value}</h1>
<div className="controls">
<button onClick={() => dispatch(increment())}>+1</button>
<button onClick={() => dispatch(decrement())}>-1</button>
<button onClick={() => dispatch(incrementByAmount(5))}>+5</button>
<button onClick={() => dispatch(reset())}>Скинути</button>
</div>
<div className="history">
<h3>Історія змін:</h3>
<p>{history.join(" → ")}</p>
</div>
</div>
);
}
export default Counter;
(state: RootState) — TypeScript виводить тип автоматично завдяки TypedUseSelectorHook. Це зменшує verbosity коду та запобігає помилкам типізації.Кілька slices: реалістичний приклад
У реальних застосунках стан складається з десятків сутностей. Кожна сутність має власний slice:
// src/store/slices/todoSlice.ts
import { createSlice, PayloadAction } from "@reduxjs/toolkit";
interface Todo {
id: number;
text: string;
completed: boolean;
}
interface TodoState {
todos: Todo[];
filter: "all" | "active" | "completed";
}
const initialState: TodoState = {
todos: [],
filter: "all",
};
let nextTodoId = 0;
const todoSlice = createSlice({
name: "todos",
initialState,
reducers: {
addTodo(state, action: PayloadAction<string>) {
state.todos.push({
id: nextTodoId++,
text: action.payload,
completed: false,
});
},
toggleTodo(state, action: PayloadAction<number>) {
const todo = state.todos.find((t) => t.id === action.payload);
if (todo) {
todo.completed = !todo.completed; // Мутація через Immer
}
},
deleteTodo(state, action: PayloadAction<number>) {
state.todos = state.todos.filter((t) => t.id !== action.payload);
},
setFilter(state, action: PayloadAction<TodoState["filter"]>) {
state.filter = action.payload;
},
clearCompleted(state) {
state.todos = state.todos.filter((t) => !t.completed);
},
},
});
export const { addTodo, toggleTodo, deleteTodo, setFilter, clearCompleted } = todoSlice.actions;
export default todoSlice.reducer;
// src/store/index.ts — комбінування slices
import { configureStore } from "@reduxjs/toolkit";
import counterReducer from "./slices/counterSlice";
import todoReducer from "./slices/todoSlice";
export const store = configureStore({
reducer: {
counter: counterReducer,
todos: todoReducer,
},
});
export type RootState = ReturnType<typeof store.getState>;
// RootState = {
// counter: CounterState,
// todos: TodoState
// }
export type AppDispatch = typeof store.dispatch;
configureStore автоматично викликає combineReducers під капотом — не потрібно імпортувати та викликати його вручну. Просто передайте об'єкт { sliceName: sliceReducer }, і RTK створить комбінований reducer.createAsyncThunk: асинхронні операції без redux-thunk
У класичному Redux для асинхронних операцій (HTTP-запити, таймери) потрібен middleware redux-thunk та ручне написання thunk-функцій із dispatch трьох actions: START, SUCCESS, ERROR. RTK спрощує це через createAsyncThunk:
// src/store/slices/userSlice.ts
import { createSlice, createAsyncThunk, PayloadAction } from "@reduxjs/toolkit";
import axios from "axios";
interface User {
id: number;
name: string;
email: string;
phone: string;
}
interface UserState {
data: User | null;
isLoading: boolean;
error: string | null;
}
const initialState: UserState = {
data: null,
isLoading: false,
error: null,
};
// Async thunk — автоматично генерує pending/fulfilled/rejected actions
export const fetchUser = createAsyncThunk<User, number>(
"user/fetch", // Префікс для actions: user/fetch/pending, user/fetch/fulfilled, ...
async (userId, { rejectWithValue }) => {
try {
const { data } = await axios.get<User>(
`https://jsonplaceholder.typicode.com/users/${userId}`
);
return data; // Стає payload у fulfilled action
} catch (error) {
if (axios.isAxiosError(error)) {
return rejectWithValue(error.response?.data?.message ?? error.message);
}
return rejectWithValue("Невідома помилка");
}
}
);
const userSlice = createSlice({
name: "user",
initialState,
reducers: {
clearUser(state) {
state.data = null;
state.error = null;
},
},
// extraReducers обробляє actions поза slice (async thunks)
extraReducers: (builder) => {
builder
.addCase(fetchUser.pending, (state) => {
state.isLoading = true;
state.error = null;
})
.addCase(fetchUser.fulfilled, (state, action: PayloadAction<User>) => {
state.isLoading = false;
state.data = action.payload;
})
.addCase(fetchUser.rejected, (state, action) => {
state.isLoading = false;
state.error = action.payload as string;
});
},
});
export const { clearUser } = userSlice.actions;
export default userSlice.reducer;
createAsyncThunk<ReturnType, ArgType>:- Перший generic — тип повернутого значення (що стане
action.payloadуfulfilled). - Другий generic — тип аргументу, який приймає async функція.
fetchUser(userId: number) повертає User.Використання async thunk у компоненті
// src/components/UserProfile.tsx
import { useEffect } from "react";
import { useAppDispatch, useAppSelector } from "../store/hooks";
import { fetchUser, clearUser } from "../store/slices/userSlice";
interface UserProfileProps {
userId: number;
}
function UserProfile({ userId }: UserProfileProps) {
const dispatch = useAppDispatch();
const { data: user, isLoading, error } = useAppSelector((state) => state.user);
useEffect(() => {
dispatch(fetchUser(userId)); // Повертає Promise, можна чекати завершення
return () => {
dispatch(clearUser()); // Cleanup при розмонтуванні
};
}, [userId, dispatch]);
if (isLoading) return <p>Завантаження профілю...</p>;
if (error) return <p className="error">Помилка: {error}</p>;
if (!user) return <p>Користувача не знайдено</p>;
return (
<div className="user-profile">
<h2>{user.name}</h2>
<p>Email: {user.email}</p>
<p>Телефон: {user.phone}</p>
</div>
);
}
export default UserProfile;
dispatch(fetchUser(userId)) повертає Promise, що дозволяє чекати на завершення:try {
const resultAction = await dispatch(fetchUser(userId));
if (fetchUser.fulfilled.match(resultAction)) {
console.log("Дані завантажено:", resultAction.payload);
}
} catch (err) {
console.error("Помилка:", err);
}
extraReducers: обробка зовнішніх actions
reducers у createSlice генерують actions автоматично. Але що робити, якщо потрібно обробити action з іншого slice або async thunk? Для цього є extraReducers:
// src/store/slices/notificationSlice.ts
import { createSlice } from "@reduxjs/toolkit";
import { fetchUser } from "./userSlice"; // Імпортуємо thunk з іншого slice
interface NotificationState {
message: string | null;
}
const initialState: NotificationState = {
message: null,
};
const notificationSlice = createSlice({
name: "notification",
initialState,
reducers: {
showNotification(state, action) {
state.message = action.payload;
},
clearNotification(state) {
state.message = null;
},
},
extraReducers: (builder) => {
// Реагуємо на успішне завантаження користувача з userSlice
builder.addCase(fetchUser.fulfilled, (state, action) => {
state.message = `Вітаємо, ${action.payload.name}!`;
});
// Реагуємо на помилку
builder.addCase(fetchUser.rejected, (state) => {
state.message = "Не вдалося завантажити профіль";
});
},
});
export const { showNotification, clearNotification } = notificationSlice.actions;
export default notificationSlice.reducer;
Тепер при успішному завершенні fetchUser автоматично з'явиться сповіщення — без явного dispatch у компоненті.
Immer: як працює «мутабельний» синтаксис
Під капотом RTK використовує бібліотеку Immer, яка перехоплює всі спроби зміни стану та перетворює їх на immutable операції. Розглянемо, що насправді відбувається:
// Те, що ви пишете у reducer
increment(state) {
state.value += 1;
state.history.push(state.value);
}
// Те, що Immer генерує під капотом (спрощена версія)
increment(state) {
return {
...state,
value: state.value + 1,
history: [...state.history, state.value + 1],
};
}
- Не можна комбінувати мутації та повернення нового стану:
// ❌ Неправильно — мутація + return
increment(state) {
state.value += 1; // Мутація
return { ...state }; // Return — Immer не застосує мутацію!
}
// ✅ Правильно — або мутація, або return
increment(state) {
state.value += 1; // Лише мутація
}
// ✅ Або повністю новий стан
increment(state) {
return { ...state, value: state.value + 1 };
}
- Не можна мутувати первинні типи (number, string, boolean):
// ❌ Неправильно
const counterSlice = createSlice({
name: "counter",
initialState: 0, // Число — первинний тип
reducers: {
increment(state) {
state += 1; // Не працює — первинні типи незмінні у JS
},
},
});
// ✅ Правильно — повернути нове значення
increment(state) {
return state + 1;
}
Селектори та createSelector для мемоїзації
Складні обчислення у селекторах (фільтрація, сортування, агрегація) повторюються при кожному рендері компонента. RTK включає reselect для створення мемоїзованих селекторів:
// src/store/slices/todoSlice.ts
import { createSlice, createSelector } from "@reduxjs/toolkit";
import type { RootState } from "../index";
// ... (код slice)
// Базові селектори — просто витягують дані зі стану
const selectTodos = (state: RootState) => state.todos.todos;
const selectFilter = (state: RootState) => state.todos.filter;
// Мемоїзований селектор — перераховує лише при зміні залежностей
export const selectVisibleTodos = createSelector(
[selectTodos, selectFilter],
(todos, filter) => {
console.log("🔄 Перерахунок видимих задач"); // Логується лише при зміні todos/filter
switch (filter) {
case "active":
return todos.filter((t) => !t.completed);
case "completed":
return todos.filter((t) => t.completed);
default:
return todos;
}
}
);
export const selectTodoStats = createSelector([selectTodos], (todos) => {
const total = todos.length;
const completed = todos.filter((t) => t.completed).length;
const active = total - completed;
return { total, completed, active };
});
Використання у компоненті:
function TodoList() {
const visibleTodos = useAppSelector(selectVisibleTodos);
const stats = useAppSelector(selectTodoStats);
return (
<div>
<p>Всього: {stats.total} | Активних: {stats.active} | Виконаних: {stats.completed}</p>
<ul>
{visibleTodos.map((todo) => (
<li key={todo.id}>{todo.text}</li>
))}
</ul>
</div>
);
}
createSelector кешує результат обчислення та повертає його, якщо вхідні дані (todos, filter) не змінилися. Це запобігає зайвим ререндерам компонентів, які підписані на селектор.Middleware у Redux Toolkit
configureStore автоматично підключає redux-thunk, але можна додати власні middleware (логування, аналітика, помилки):
// src/store/middleware/logger.ts
import { Middleware } from "@reduxjs/toolkit";
export const loggerMiddleware: Middleware = (storeAPI) => (next) => (action) => {
console.log("⚡ Action:", action.type);
console.log("📦 Поточний стан:", storeAPI.getState());
const result = next(action); // Передаємо action далі
console.log("✅ Новий стан:", storeAPI.getState());
return result;
};
// src/store/index.ts
import { configureStore } from "@reduxjs/toolkit";
import counterReducer from "./slices/counterSlice";
import { loggerMiddleware } from "./middleware/logger";
export const store = configureStore({
reducer: {
counter: counterReducer,
},
middleware: (getDefaultMiddleware) =>
getDefaultMiddleware().concat(loggerMiddleware),
});
getDefaultMiddleware() повертає стандартний набір middleware (redux-thunk, перевірки immutability у dev-режимі). Викликайте .concat() для додавання власних middleware, щоб не втратити стандартні.Практичний приклад: книгарня з асинхронними операціями
Об'єднаємо всі концепції RTK у повноцінному CRUD-застосунку для управління книгами:
// src/store/slices/booksSlice.ts
import { createSlice, createAsyncThunk, PayloadAction } from "@reduxjs/toolkit";
import axios from "axios";
interface Book {
id: number;
title: string;
author: string;
year: number;
genre: string;
price: number;
}
interface BooksState {
items: Book[];
isLoading: boolean;
error: string | null;
filter: string;
}
const initialState: BooksState = {
items: [],
isLoading: false,
error: null,
filter: "all",
};
// Async thunks для API-взаємодії
export const fetchBooks = createAsyncThunk<Book[]>(
"books/fetchAll",
async () => {
const { data } = await axios.get<Book[]>("http://localhost:3001/books");
return data;
}
);
export const addBook = createAsyncThunk<Book, Omit<Book, "id">>(
"books/add",
async (bookData) => {
const { data } = await axios.post<Book>("http://localhost:3001/books", bookData);
return data;
}
);
export const deleteBook = createAsyncThunk<number, number>(
"books/delete",
async (id) => {
await axios.delete(`http://localhost:3001/books/${id}`);
return id; // Повертаємо ID для видалення з масиву
}
);
const booksSlice = createSlice({
name: "books",
initialState,
reducers: {
setFilter(state, action: PayloadAction<string>) {
state.filter = action.payload;
},
},
extraReducers: (builder) => {
// Fetch books
builder
.addCase(fetchBooks.pending, (state) => {
state.isLoading = true;
state.error = null;
})
.addCase(fetchBooks.fulfilled, (state, action) => {
state.isLoading = false;
state.items = action.payload;
})
.addCase(fetchBooks.rejected, (state, action) => {
state.isLoading = false;
state.error = action.error.message ?? "Помилка завантаження";
});
// Add book
builder
.addCase(addBook.fulfilled, (state, action) => {
state.items.push(action.payload);
})
.addCase(addBook.rejected, (state, action) => {
state.error = action.error.message ?? "Помилка додавання";
});
// Delete book
builder
.addCase(deleteBook.fulfilled, (state, action) => {
state.items = state.items.filter((book) => book.id !== action.payload);
})
.addCase(deleteBook.rejected, (state, action) => {
state.error = action.error.message ?? "Помилка видалення";
});
},
});
export const { setFilter } = booksSlice.actions;
export default booksSlice.reducer;
// src/store/slices/statsSlice.ts — селектори для статистики
import { createSelector } from "@reduxjs/toolkit";
import type { RootState } from "../index";
const selectBooks = (state: RootState) => state.books.items;
export const selectBookStats = createSelector([selectBooks], (books) => {
if (books.length === 0) {
return { total: 0, avgPrice: 0, genres: [] };
}
const total = books.length;
const avgPrice = books.reduce((sum, b) => sum + b.price, 0) / total;
const genres = [...new Set(books.map((b) => b.genre))];
return { total, avgPrice: Math.round(avgPrice), genres };
});
export const selectFilteredBooks = createSelector(
[selectBooks, (state: RootState) => state.books.filter],
(books, filter) => {
if (filter === "all") return books;
return books.filter((b) => b.genre === filter);
}
);
// src/components/BookList.tsx
import { useEffect } from "react";
import { useAppDispatch, useAppSelector } from "../store/hooks";
import { fetchBooks, deleteBook, setFilter } from "../store/slices/booksSlice";
import { selectFilteredBooks, selectBookStats } from "../store/slices/statsSlice";
function BookList() {
const dispatch = useAppDispatch();
const books = useAppSelector(selectFilteredBooks);
const stats = useAppSelector(selectBookStats);
const { isLoading, error, filter } = useAppSelector((state) => state.books);
useEffect(() => {
dispatch(fetchBooks());
}, [dispatch]);
if (isLoading) return <p>Завантаження...</p>;
if (error) return <p className="error">Помилка: {error}</p>;
return (
<div className="bookstore">
<header>
<h1>Книгарня</h1>
<div className="stats">
<p>📚 Всього книг: {stats.total}</p>
<p>💰 Середня ціна: {stats.avgPrice} ₴</p>
</div>
</header>
<div className="filters">
<button onClick={() => dispatch(setFilter("all"))}>Всі</button>
{stats.genres.map((genre) => (
<button key={genre} onClick={() => dispatch(setFilter(genre))}>
{genre}
</button>
))}
</div>
<ul className="books-list">
{books.map((book) => (
<li key={book.id}>
<h3>{book.title}</h3>
<p>Автор: {book.author}</p>
<p>Жанр: {book.genre}</p>
<p>Ціна: {book.price} ₴</p>
<button onClick={() => dispatch(deleteBook(book.id))}>Видалити</button>
</li>
))}
</ul>
</div>
);
}
export default BookList;
Redux Toolkit vs Класичний Redux: детальне порівняння
| Критерій | Класичний Redux | Redux Toolkit |
|---|---|---|
| Обсяг boilerplate | ~70–100 рядків на reducer | ~15–20 рядків на slice |
| Action creators | Ручне написання функцій | Генеруються автоматично createSlice |
| Immutable updates | Ручний spread-синтаксис ({ ...state }) | «Мутабельний» синтаксис через Immer |
| Async logic | redux-thunk + ручні thunk-функції | createAsyncThunk із автоматичними pending/fulfilled |
| Store configuration | createStore + applyMiddleware + DevTools | configureStore — все автоматично |
| Типізація TypeScript | Ручна для actions, reducers, state | Автоматична у 90% випадків |
| combineReducers | Ручний виклик | Автоматично у configureStore |
| DevTools | Ручне підключення composeEnhancers | Автоматично |
| Middleware | Ручне додавання applyMiddleware | getDefaultMiddleware().concat(...) |
| Мемоїзовані селектори | Потрібен окремий npm install reselect | Вбудований createSelector |
| Перевірки immutability у dev | Немає | Автоматично увімкнені |
| Кількість залежностей | redux + react-redux + redux-thunk + reselect | @reduxjs/toolkit + react-redux |
Best Practices Redux Toolkit
✅ Один slice = одна логічна сутність
✅ Селектори поза slice
createSelector виносьте у окремий файл (selectors/) або принаймні поза межі slice. Це спрощує тестування та повторне використання.✅ Нормалізація стану
{ byId: { 1: {...}, 2: {...} }, allIds: [1, 2] }. RTK надає утиліту createEntityAdapter для автоматичної нормалізації.✅ Типізовані хуки у окремому файлі
store/hooks.ts з useAppDispatch та useAppSelector один раз для всього проєкту. Імпортуйте їх замість оригінальних хуків react-redux.⚠️ Не мутуйте первинні типи
initialState: 0) використовуйте return newValue, а не state += 1.⚠️ Не комбінуйте мутації та return
Питання для самоконтролю
Хоча синтаксис виглядає як мутація (state.value += 1), Immer не модифікує оригінальний об'єкт. Замість цього він:
- Створює proxy (проксі-обгортку) над оригінальним станом.
- Записує всі спроби зміни як «рецепт» (recipe).
- Після завершення reducer-функції застосовує рецепт до копії стану, створюючи новий immutable об'єкт.
- Повертає цю копію як новий стан.
Оригінальний state залишається незмінним, тому reducer залишається чистою функцією з точки зору Redux. Це еквівалентно ручному spread-синтаксису, але з простішим кодом.
Технічна деталь: Immer використовує ES6 Proxy API для перехоплення операцій (get, set, deleteProperty) та Structural Sharing для оптимізації пам'яті — незмінені частини стану розділяються між старою та новою версіями.
reducers — для actions, які належать цьому slice:
- Синхронні операції зміни стану (додати задачу, перемкнути прапорець, змінити фільтр).
- Actions генеруються автоматично:
sliceName.actions.actionName().
extraReducers — для actions з інших джерел:
- Async thunks (
createAsyncThunk) — pending/fulfilled/rejected actions. - Actions з інших slices (один slice реагує на action іншого).
- Actions зі сторонніх бібліотек або middleware.
Приклад:
const todoSlice = createSlice({
name: "todos",
initialState,
// reducers — дії цього slice
reducers: {
addTodo(state, action) { /* ... */ },
toggleTodo(state, action) { /* ... */ },
},
// extraReducers — реакція на зовнішні дії
extraReducers: (builder) => {
builder.addCase(fetchTodos.fulfilled, (state, action) => {
state.items = action.payload; // Обробка async thunk
});
builder.addCase(userLoggedOut, (state) => {
return initialState; // Реакція на action з userSlice
});
},
});
Теоретично можна викликати API напряму у useEffect та dispatch синхронних actions:
// ❌ Не рекомендується — логіка розкидана по компонентах
useEffect(() => {
async function load() {
dispatch(setLoading(true));
try {
const { data } = await axios.get("/users");
dispatch(setUsers(data));
} catch (err) {
dispatch(setError(err.message));
} finally {
dispatch(setLoading(false));
}
}
load();
}, []);
Проблеми цього підходу:
- Логіка завантаження дублюється у кожному компоненті, що викликає API.
- Неможливо тестувати логіку завантаження ізольовано від компонента.
- Redux DevTools не показує проміжні стани (pending, fulfilled, rejected) — лише фінальні actions.
- Складно скасувати запити при розмонтуванні (потрібен
AbortControllerу кожному useEffect).
Переваги createAsyncThunk:
- Логіка централізована у slice — тестується окремо.
- Автоматична генерація pending/fulfilled/rejected actions для Redux DevTools.
- Підтримка
AbortControllerчерезthunkAPI.signal. - Типізація аргументів та повернутого значення.
Практичне завдання
Створіть RTK-застосунок для управління контактами з наступними вимогами:
Моделі даних:
interface Contact {
id: number;
name: string;
phone: string;
email: string;
isFavorite: boolean;
}
Функціональність:
- Завантаження контактів із JSON Server (
GET /contacts) - Додавання нового контакту (
POST /contacts) - Видалення контакту (
DELETE /contacts/:id) - Позначення контакту як улюбленого (локально, без API)
- Фільтрація: всі / лише улюблені
- Пошук за ім'ям (локально)
- Статистика: загальна кількість, кількість улюблених
Вимоги до реалізації:
- Використати
createSliceтаcreateAsyncThunk - Селектори для фільтрації через
createSelector - Типізовані хуки
useAppDispatchтаuseAppSelector - Обробка станів
isLoadingтаerror
JSON Server (для тестування):
npx json-server --watch db.json --port 3001
db.json:
{
"contacts": [
{ "id": 1, "name": "Олена Коваленко", "phone": "+380501234567", "email": "olena@example.com", "isFavorite": true },
{ "id": 2, "name": "Андрій Мельник", "phone": "+380672345678", "email": "andriy@example.com", "isFavorite": false }
]
}
// src/store/slices/contactsSlice.ts
import { createSlice, createAsyncThunk, createSelector, PayloadAction } from "@reduxjs/toolkit";
import axios from "axios";
import type { RootState } from "../index";
interface Contact {
id: number;
name: string;
phone: string;
email: string;
isFavorite: boolean;
}
interface ContactsState {
items: Contact[];
isLoading: boolean;
error: string | null;
filter: "all" | "favorites";
searchQuery: string;
}
const initialState: ContactsState = {
items: [],
isLoading: false,
error: null,
filter: "all",
searchQuery: "",
};
// Async thunks
export const fetchContacts = createAsyncThunk<Contact[]>(
"contacts/fetchAll",
async () => {
const { data } = await axios.get<Contact[]>("http://localhost:3001/contacts");
return data;
}
);
export const addContact = createAsyncThunk<Contact, Omit<Contact, "id">>(
"contacts/add",
async (contactData) => {
const { data } = await axios.post<Contact>("http://localhost:3001/contacts", contactData);
return data;
}
);
export const deleteContact = createAsyncThunk<number, number>(
"contacts/delete",
async (id) => {
await axios.delete(`http://localhost:3001/contacts/${id}`);
return id;
}
);
const contactsSlice = createSlice({
name: "contacts",
initialState,
reducers: {
toggleFavorite(state, action: PayloadAction<number>) {
const contact = state.items.find((c) => c.id === action.payload);
if (contact) {
contact.isFavorite = !contact.isFavorite;
}
},
setFilter(state, action: PayloadAction<ContactsState["filter"]>) {
state.filter = action.payload;
},
setSearchQuery(state, action: PayloadAction<string>) {
state.searchQuery = action.payload;
},
},
extraReducers: (builder) => {
builder
.addCase(fetchContacts.pending, (state) => {
state.isLoading = true;
state.error = null;
})
.addCase(fetchContacts.fulfilled, (state, action) => {
state.isLoading = false;
state.items = action.payload;
})
.addCase(fetchContacts.rejected, (state, action) => {
state.isLoading = false;
state.error = action.error.message ?? "Помилка завантаження";
})
.addCase(addContact.fulfilled, (state, action) => {
state.items.push(action.payload);
})
.addCase(deleteContact.fulfilled, (state, action) => {
state.items = state.items.filter((c) => c.id !== action.payload);
});
},
});
export const { toggleFavorite, setFilter, setSearchQuery } = contactsSlice.actions;
// Селектори
const selectContacts = (state: RootState) => state.contacts.items;
const selectFilter = (state: RootState) => state.contacts.filter;
const selectSearchQuery = (state: RootState) => state.contacts.searchQuery;
export const selectVisibleContacts = createSelector(
[selectContacts, selectFilter, selectSearchQuery],
(contacts, filter, query) => {
let filtered = contacts;
if (filter === "favorites") {
filtered = filtered.filter((c) => c.isFavorite);
}
if (query.trim()) {
filtered = filtered.filter((c) =>
c.name.toLowerCase().includes(query.toLowerCase())
);
}
return filtered;
}
);
export const selectContactStats = createSelector([selectContacts], (contacts) => ({
total: contacts.length,
favorites: contacts.filter((c) => c.isFavorite).length,
}));
export default contactsSlice.reducer;
// src/components/ContactList.tsx
import { useEffect } from "react";
import { useAppDispatch, useAppSelector } from "../store/hooks";
import {
fetchContacts,
deleteContact,
toggleFavorite,
setFilter,
setSearchQuery,
selectVisibleContacts,
selectContactStats,
} from "../store/slices/contactsSlice";
function ContactList() {
const dispatch = useAppDispatch();
const contacts = useAppSelector(selectVisibleContacts);
const stats = useAppSelector(selectContactStats);
const { isLoading, error, filter, searchQuery } = useAppSelector((state) => state.contacts);
useEffect(() => {
dispatch(fetchContacts());
}, [dispatch]);
if (isLoading) return <p>Завантаження контактів...</p>;
if (error) return <p className="error">Помилка: {error}</p>;
return (
<div className="contacts-app">
<header>
<h1>Контакти</h1>
<p>Всього: {stats.total} | Улюблених: {stats.favorites}</p>
</header>
<div className="controls">
<input
type="text"
placeholder="Пошук за ім'ям..."
value={searchQuery}
onChange={(e) => dispatch(setSearchQuery(e.target.value))}
/>
<div className="filters">
<button onClick={() => dispatch(setFilter("all"))}>Всі</button>
<button onClick={() => dispatch(setFilter("favorites"))}>Улюблені</button>
</div>
</div>
<ul className="contacts-list">
{contacts.map((contact) => (
<li key={contact.id}>
<div className="contact-info">
<h3>{contact.name}</h3>
<p>📞 {contact.phone}</p>
<p>📧 {contact.email}</p>
</div>
<div className="actions">
<button onClick={() => dispatch(toggleFavorite(contact.id))}>
{contact.isFavorite ? "⭐" : "☆"}
</button>
<button onClick={() => dispatch(deleteContact(contact.id))}>Видалити</button>
</div>
</li>
))}
</ul>
{contacts.length === 0 && <p className="empty">Контакти не знайдено</p>}
</div>
);
}
export default ContactList;
Ключові моменти реалізації:
- Імутабельність через Immer:
contact.isFavorite = !contact.isFavoriteвиглядає як мутація, але Immer перетворює це на immutable update. - Мемоїзовані селектори:
selectVisibleContactsперераховує фільтрацію лише при змініcontacts,filterабоsearchQuery. - Автоматична типізація: TypeScript виводить типи
action.payloadу всіх extraReducers завдяки genericcreateAsyncThunk<ReturnType, ArgType>.
Підсумок
✅ Опановані навички
- Створення slices через
createSliceіз автоматичною генерацією actions - Асинхронні операції через
createAsyncThunkбез ручних thunk-функцій - «Мутабельний» синтаксис через Immer для простішого коду
- Типізовані хуки
useAppDispatchтаuseAppSelector - Мемоїзовані селектори через
createSelector
🎯 Наступні кроки
Redux: класичний підхід до глобального стану
Опанування класичного Redux від основ до інтеграції з React: actions, reducers, store, middleware, react-redux хуки та порівняння з Context API
RTK Query: запити до сервера без болю
Покрокове введення в RTK Query: від першого запиту до кешування, мутацій, інвалідації тегів, оптимістичних оновлень, пагінації, автентифікації та типізації