Redux: класичний підхід до глобального стану
Redux: класичний підхід до глобального стану
🎯 Мета теми
- Зрозуміти фундаментальні концепції Redux незалежно від React: actions, reducers, store, subscribe.
- Опанувати еволюцію Redux від чистого JavaScript до типізованої інтеграції з React через react-redux.
- Навчитися структурувати глобальний стан за принципами immutability та pure functions.
- Інтегрувати middleware (redux-thunk) для обробки асинхронних операцій.
🔑 Ключові терміни
- Store: єдине джерело правди (single source of truth) — центральний контейнер стану застосунку.
- Action: звичайний JavaScript-об'єкт із полем
type, що описує подію у системі. - Reducer: чиста функція
(state, action) => newState, яка визначає, як стан змінюється у відповідь на action. - Dispatch: метод відправки action до store, який викликає reducer та оновлює стан.
Чому Redux, якщо є Context API?
Після вивчення Context API у студентів часто виникає питання: навіщо вивчати Redux, якщо React уже має вбудований механізм передачі стану через дерево компонентів? Щоб відповісти на це питання, важливо зрозуміти фундаментальну різницю між цими підходами.
Context API — це механізм React для уникнення «prop drilling» (передачі пропсів через багато рівнів компонентів). Він вирішує проблему доступності даних, але не надає інструментів для управління складним станом. Як тільки логіка стану виходить за межі useState та useReducer, Context перетворюється на транспортний канал для довільних значень без структури, історії змін чи інструментів налагодження.
Redux — це архітектурний патерн із суворими правилами організації стану:
// Context лише транспортує стан — логіка залишається у компонентах
const CartContext = createContext<CartState | null>(null);
function CartProvider({ children }: { children: React.ReactNode }) {
const [items, setItems] = useState<CartItem[]>([]);
// Логіка розкидана по компоненту
const addItem = (product: Product) => {
setItems([...items, { ...product, quantity: 1 }]);
};
const removeItem = (id: number) => {
setItems(items.filter((item) => item.id !== id));
};
return (
<CartContext.Provider value={{ items, addItem, removeItem }}>
{children}
</CartContext.Provider>
);
}
// Redux відокремлює стан від UI — логіка у редюсері
interface CartState {
items: CartItem[];
}
const initialState: CartState = { items: [] };
function cartReducer(state = initialState, action: CartAction): CartState {
switch (action.type) {
case "cart/addItem":
return { items: [...state.items, action.payload] };
case "cart/removeItem":
return { items: state.items.filter((item) => item.id !== action.payload) };
default:
return state;
}
}
// Компоненти лише диспатчать actions — логіка ізольована
dispatch({ type: "cart/addItem", payload: product });
- Стан простий (2–3 поля без складних обчислень).
- Оновлення рідкісні (тема застосунку, налаштування локалізації).
- Проєкт невеликий (до 10–15 компонентів, що споживають контекст).
- Стан складний (вкладені об'єкти, масиви, залежності між даними).
- Потрібна історія змін для відладки (Redux DevTools — time-travel debugging).
- Багато компонентів незалежно читають і змінюють різні частини стану.
- Потрібна передбачувана логіка з тестованими редюсерами.
Redux без React: філософія через практику
Перш ніж інтегрувати Redux у React, важливо зрозуміти, що Redux — автономна бібліотека, яка працює у будь-якому JavaScript-оточенні. Розпочнемо з найпростішого прикладу у звичайному HTML-файлі без жодних фреймворків.
Лічильник у чистому JavaScript
Створіть файл index.html та відкрийте його у браузері:
<!DOCTYPE html>
<html lang="uk">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Redux без React</title>
<style>
body {
font-family: system-ui, sans-serif;
padding: 2rem;
background: #f8fafc;
}
.counter {
background: white;
padding: 2rem;
border-radius: 0.5rem;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
max-width: 400px;
}
button {
padding: 0.5rem 1rem;
margin: 0.25rem;
border: none;
border-radius: 0.375rem;
font-weight: 600;
cursor: pointer;
background: #3b82f6;
color: white;
}
button:hover {
background: #2563eb;
}
</style>
</head>
<body>
<div class="counter">
<h1>Лічильник: <span id="value">0</span></h1>
<button id="increment">+1</button>
<button id="decrement">-1</button>
<button id="reset">Скинути</button>
<button id="incrementIfOdd">Додати, якщо непарне</button>
</div>
<!-- Підключаємо Redux із CDN -->
<script src="https://unpkg.com/redux@4.2.1/dist/redux.min.js"></script>
<script>
// 1️⃣ REDUCER: чиста функція, що описує зміну стану
function counterReducer(state = 0, action) {
switch (action.type) {
case "INCREMENT":
return state + 1;
case "DECREMENT":
return state - 1;
case "RESET":
return 0;
default:
return state; // Незмінений стан для невідомих actions
}
}
// 2️⃣ STORE: створення сховища з редюсером
const store = Redux.createStore(counterReducer);
// Посилання на DOM-елемент
const valueEl = document.getElementById("value");
// 3️⃣ SUBSCRIBE: реєстрація слухача змін стану
function render() {
const currentState = store.getState();
valueEl.textContent = currentState;
console.log("Новий стан:", currentState);
}
render(); // Початкове відображення
store.subscribe(render); // Викликати render при кожній зміні стану
// 4️⃣ DISPATCH: відправка actions
document.getElementById("increment").addEventListener("click", () => {
store.dispatch({ type: "INCREMENT" });
});
document.getElementById("decrement").addEventListener("click", () => {
store.dispatch({ type: "DECREMENT" });
});
document.getElementById("reset").addEventListener("click", () => {
store.dispatch({ type: "RESET" });
});
document.getElementById("incrementIfOdd").addEventListener("click", () => {
const currentValue = store.getState();
if (currentValue % 2 !== 0) {
store.dispatch({ type: "INCREMENT" });
}
});
</script>
</body>
</html>
dispatch(action) → reducer(state, action) → новий стан → subscribe(callback) → оновлення UI. Redux ніколи не змінює стан напряму — редюсер завжди повертає новий об'єкт стану.Три принципи Redux
Redux базується на трьох фундаментальних принципах, які гарантують передбачуваність та можливість відладки будь-якого застосунку:
1. Єдине джерело правди (Single Source of Truth)
Весь стан застосунку зберігається в одному дереві об'єктів всередині одного store. Це спрощує налагодження, серіалізацію стану (збереження у localStorage) та розуміння поточного стану системи.
// Неправильно — кілька незалежних сторів
const userStore = createStore(userReducer);
const cartStore = createStore(cartReducer);
const ordersStore = createStore(ordersReducer);
// Правильно — один стор з комбінованим редюсером
const rootReducer = combineReducers({
user: userReducer,
cart: cartReducer,
orders: ordersReducer,
});
const store = createStore(rootReducer);
// Структура стану:
// {
// user: { id: 1, name: "Олена" },
// cart: { items: [...] },
// orders: { list: [...] }
// }
2. Стан доступний лише для читання (State is Read-Only)
Єдиний спосіб змінити стан — відправити action (дію). Неможливо змінити стан напряму через присвоєння або мутацію об'єктів. Це гарантує, що жодна частина коду (view-логіка, middleware, сторонні бібліотеки) не може несподівано змінити глобальний стан.
// ❌ Неправильно — пряма мутація заборонена
store.getState().user.name = "Андрій";
// ✅ Правильно — зміна через action
store.dispatch({
type: "user/setName",
payload: "Андрій",
});
3. Зміни виконуються чистими функціями (Changes are Made with Pure Functions)
Редюсери — це чисті функції, які приймають попередній стан та action, і повертають новий стан. Чисті функції:
- Не мають побічних ефектів (не викликають API, не змінюють DOM, не генерують випадкові числа).
- Детерміновані: однаковий вхід завжди дає однаковий вихід.
- Не модифікують аргументи (
stateтаactionзалишаються незмінними).
// ❌ Неправильно — мутація state
function cartReducer(state = { items: [] }, action) {
if (action.type === "cart/addItem") {
state.items.push(action.payload); // Мутація!
return state;
}
return state;
}
// ✅ Правильно — створення нового state
function cartReducer(state = { items: [] }, action) {
if (action.type === "cart/addItem") {
return {
...state,
items: [...state.items, action.payload], // Новий масив
};
}
return state;
}
useSelector може пропустити перерендер. Завжди використовуйте spread-оператор (...) або бібліотеку Immer (вбудована у Redux Toolkit).Анатомія Redux: розбір фундаментальних концепцій
Actions: описуємо події системи
Action — це звичайний JavaScript-об'єкт, який описує подію, що сталася у застосунку. Єдина обов'язкова властивість — type (рядок, що ідентифікує дію). Додатково action може містити payload (корисне навантаження) — дані, необхідні для обробки події.
// Прості actions без payload
{ type: "user/logout" }
{ type: "cart/clear" }
// Actions із payload
{ type: "user/setName", payload: "Олена Коваленко" }
{ type: "cart/addItem", payload: { id: 5, name: "Ноутбук", price: 25000 } }
{ type: "todos/toggleComplete", payload: 42 } // ID задачі
Для зручності створюють action creators — функції, що генерують actions:
// Action creators — фабрики дій
function setUserName(name: string) {
return { type: "user/setName", payload: name };
}
function addToCart(product: Product) {
return { type: "cart/addItem", payload: product };
}
function toggleTodo(id: number) {
return { type: "todos/toggleComplete", payload: id };
}
// Використання
store.dispatch(setUserName("Андрій"));
store.dispatch(addToCart({ id: 10, name: "Мишка", price: 800 }));
store.dispatch(toggleTodo(5));
domain/eventName (наприклад, cart/addItem, user/login, todos/delete). Це запобігає колізіям імен у великих застосунках із десятками редюсерів. У Redux Toolkit цей формат стає стандартом через createSlice.Reducers: чисті функції трансформації стану
Reducer — це функція з сигнатурою (state, action) => newState. Вона приймає поточний стан та action, і повертає новий стан. Редюсер не змінює (mutate) аргумент state — він конструює нову структуру даних.
interface CounterState {
value: number;
history: number[];
}
const initialState: CounterState = {
value: 0,
history: [],
};
function counterReducer(
state: CounterState = initialState,
action: { type: string; payload?: number }
): CounterState {
switch (action.type) {
case "counter/increment":
return {
value: state.value + 1,
history: [...state.history, state.value + 1], // Новий масив
};
case "counter/decrement":
return {
value: state.value - 1,
history: [...state.history, state.value - 1],
};
case "counter/incrementByAmount":
const newValue = state.value + (action.payload ?? 0);
return {
value: newValue,
history: [...state.history, newValue],
};
case "counter/reset":
return initialState; // Повертаємо початковий стан
default:
return state; // Важливо: для невідомих actions повертаємо незмінений стан
}
}
state має значення за замовчуванням (= initialState). Це гарантує, що при першому виклику редюсера (коли Redux ініціалізує store), функція отримає початковий стан замість undefined.Store: контейнер стану та оркестратор
Store — це об'єкт, що поєднує всі частини Redux у єдину систему. Він:
- Зберігає дерево стану застосунку.
- Надає метод
getState()для читання поточного стану. - Надає метод
dispatch(action)для зміни стану. - Реєструє слухачів через
subscribe(listener).
import { createStore } from "redux";
// Створення store з редюсером
const store = createStore(counterReducer);
// Читання стану
console.log(store.getState()); // { value: 0, history: [] }
// Відправка action
store.dispatch({ type: "counter/increment" });
console.log(store.getState()); // { value: 1, history: [1] }
// Підписка на зміни
const unsubscribe = store.subscribe(() => {
console.log("Стан змінився:", store.getState());
});
// Видалення підписки
unsubscribe();
createStore напряму у компонентах React. Store створюється один раз при запуску застосунку і передається через <Provider> з бібліотеки react-redux. Повторний виклик createStore створить окремий ізольований стор, що призведе до плутанини.Модульна архітектура: від одного файлу до структурованого проєкту
У попередньому прикладі весь код розміщений в одному HTML-файлі. У реальних застосунках actions, reducers та store організовані у модульну структуру:
src/
├── store/
│ ├── index.ts # Створення та експорт store
│ ├── actions/
│ │ ├── todoActions.ts # Action creators для задач
│ │ └── userActions.ts # Action creators для користувача
│ ├── reducers/
│ │ ├── todoReducer.ts # Редюсер задач
│ │ ├── userReducer.ts # Редюсер користувача
│ │ └── rootReducer.ts # Комбінований редюсер
│ └── types.ts # TypeScript типи для actions та state
Action Types: константи для уникнення помилок
Хард-кодити рядки типів actions ("counter/increment") у кількох місцях — погана практика. Друкарська помилка призведе до мовчазного ігнорування action. Винесемо типи у константи:
// src/store/types.ts
export const TODO_ADD = "todos/add";
export const TODO_TOGGLE = "todos/toggle";
export const TODO_DELETE = "todos/delete";
export const TODO_CLEAR_COMPLETED = "todos/clearCompleted";
// TypeScript типи для actions
export interface Todo {
id: number;
text: string;
completed: boolean;
}
export type TodoAction =
| { type: typeof TODO_ADD; payload: string }
| { type: typeof TODO_TOGGLE; payload: number }
| { type: typeof TODO_DELETE; payload: number }
| { type: typeof TODO_CLEAR_COMPLETED };
Action Creators: типізовані фабрики дій
// src/store/actions/todoActions.ts
import { TODO_ADD, TODO_TOGGLE, TODO_DELETE, TODO_CLEAR_COMPLETED, TodoAction } from "../types";
let nextTodoId = 0;
export function addTodo(text: string): TodoAction {
return {
type: TODO_ADD,
payload: text,
};
}
export function toggleTodo(id: number): TodoAction {
return {
type: TODO_TOGGLE,
payload: id,
};
}
export function deleteTodo(id: number): TodoAction {
return {
type: TODO_DELETE,
payload: id,
};
}
export function clearCompletedTodos(): TodoAction {
return {
type: TODO_CLEAR_COMPLETED,
};
}
Reducer: типізована логіка трансформації стану
// src/store/reducers/todoReducer.ts
import { TODO_ADD, TODO_TOGGLE, TODO_DELETE, TODO_CLEAR_COMPLETED, Todo, TodoAction } from "../types";
export interface TodoState {
todos: Todo[];
}
const initialState: TodoState = {
todos: [],
};
let nextTodoId = 0;
export function todoReducer(state = initialState, action: TodoAction): TodoState {
switch (action.type) {
case TODO_ADD:
return {
todos: [
...state.todos,
{
id: nextTodoId++,
text: action.payload,
completed: false,
},
],
};
case TODO_TOGGLE:
return {
todos: state.todos.map((todo) =>
todo.id === action.payload
? { ...todo, completed: !todo.completed }
: todo
),
};
case TODO_DELETE:
return {
todos: state.todos.filter((todo) => todo.id !== action.payload),
};
case TODO_CLEAR_COMPLETED:
return {
todos: state.todos.filter((todo) => !todo.completed),
};
default:
return state;
}
}
.map() створює новий масив, але копіює посилання на об'єкти. Щоб уникнути мутації об'єкта todo, ми використовуємо spread-оператор { ...todo, completed: !todo.completed }, який створює новий об'єкт із зміненою властивістю.Комбінування редюсерів для складного стану
Реальні застосунки мають десятки сутностей (users, posts, comments, cart, orders). Кожна сутність має власний редюсер, який відповідає лише за свою частину стану. Функція combineReducers об'єднує їх у єдиний кореневий редюсер:
// src/store/reducers/rootReducer.ts
import { combineReducers } from "redux";
import { todoReducer } from "./todoReducer";
import { userReducer } from "./userReducer";
import { cartReducer } from "./cartReducer";
const rootReducer = combineReducers({
todos: todoReducer,
user: userReducer,
cart: cartReducer,
});
export type RootState = ReturnType<typeof rootReducer>;
// RootState = {
// todos: TodoState,
// user: UserState,
// cart: CartState
// }
export default rootReducer;
// src/store/index.ts — створення store
import { createStore } from "redux";
import rootReducer from "./reducers/rootReducer";
const store = createStore(rootReducer);
export default store;
RootState автоматично виводиться з структури комбінованих редюсерів. Це дозволяє TypeScript перевіряти типи у всіх селекторах стану без ручного дублювання інтерфейсів.Інтеграція Redux із React через react-redux
Тепер, коли ми розуміємо Redux у відриві від React, інтеграція стане очевидною. Бібліотека react-redux надає два ключові інструменти:
<Provider store={store}>— компонент, що робить store доступним для всього дерева React.- Хуки
useSelectorтаuseDispatch— сучасний спосіб читання стану та відправки actions.
npm install react-redux
npm install --save-dev @types/react-redux
pnpm add react-redux
pnpm add -D @types/react-redux
Налаштування Provider у кореневому компоненті
// src/main.tsx — точка входу
import React from "react";
import ReactDOM from "react-dom/client";
import { Provider } from "react-redux";
import store from "./store";
import App from "./App";
import "./index.css";
ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode>
<Provider store={store}>
<App />
</Provider>
</React.StrictMode>
);
<Provider> використовує React Context під капотом, щоб передати store всім компонентам-нащадкам. Однак на відміну від звичайного Context, react-redux оптимізує перерендери: компоненти оновлюються лише при зміні тих частин стану, які вони безпосередньо читають через useSelector.useSelector: читання стану з типізацією
Хук useSelector приймає функцію-селектор, яка витягує потрібну частину стану зі store:
// src/components/TodoList.tsx
import { useSelector } from "react-redux";
import { RootState } from "../store/reducers/rootReducer";
import { Todo } from "../store/types";
function TodoList() {
// Типізований селектор: TypeScript знає структуру RootState
const todos = useSelector((state: RootState) => state.todos.todos);
return (
<ul className="todo-list">
{todos.map((todo) => (
<li key={todo.id} className={todo.completed ? "completed" : ""}>
<span>{todo.text}</span>
</li>
))}
</ul>
);
}
export default TodoList;
Селектори з обчисленнями:
function TodoStats() {
const totalCount = useSelector((state: RootState) => state.todos.todos.length);
const completedCount = useSelector(
(state: RootState) => state.todos.todos.filter((t) => t.completed).length
);
const activeCount = totalCount - completedCount;
return (
<div className="stats">
<p>Всього: {totalCount}</p>
<p>Активних: {activeCount}</p>
<p>Виконаних: {completedCount}</p>
</div>
);
}
useSelector створює нову функцію-селектор. Якщо селектор виконує складні обчислення (фільтрацію, сортування, агрегацію), ці обчислення повторюються при кожному рендері компонента. Для складних селекторів використовуйте бібліотеку reselect (мемоїзовані селектори) або Redux Toolkit (вбудована createSelector).useDispatch: відправка actions
Хук useDispatch повертає функцію dispatch, через яку відправляються actions:
// src/components/AddTodoForm.tsx
import { useState } from "react";
import { useDispatch } from "react-redux";
import { addTodo } from "../store/actions/todoActions";
function AddTodoForm() {
const [text, setText] = useState("");
const dispatch = useDispatch();
function handleSubmit(e: React.FormEvent) {
e.preventDefault();
if (text.trim()) {
dispatch(addTodo(text)); // Відправка action через action creator
setText("");
}
}
return (
<form onSubmit={handleSubmit} className="add-todo-form">
<input
type="text"
value={text}
onChange={(e) => setText(e.target.value)}
placeholder="Нова задача..."
/>
<button type="submit">Додати</button>
</form>
);
}
export default AddTodoForm;
Повний компонент із useSelector та useDispatch
// src/components/TodoItem.tsx
import { useDispatch } from "react-redux";
import { toggleTodo, deleteTodo } from "../store/actions/todoActions";
import { Todo } from "../store/types";
interface TodoItemProps {
todo: Todo;
}
function TodoItem({ todo }: TodoItemProps) {
const dispatch = useDispatch();
return (
<li className={`todo-item ${todo.completed ? "completed" : ""}`}>
<input
type="checkbox"
checked={todo.completed}
onChange={() => dispatch(toggleTodo(todo.id))}
/>
<span>{todo.text}</span>
<button onClick={() => dispatch(deleteTodo(todo.id))}>Видалити</button>
</li>
);
}
export default TodoItem;
// src/App.tsx — збірка компонентів
import { useSelector } from "react-redux";
import { RootState } from "./store/reducers/rootReducer";
import AddTodoForm from "./components/AddTodoForm";
import TodoItem from "./components/TodoItem";
function App() {
const todos = useSelector((state: RootState) => state.todos.todos);
return (
<div className="app">
<h1>Список задач (Redux)</h1>
<AddTodoForm />
<ul className="todo-list">
{todos.map((todo) => (
<TodoItem key={todo.id} todo={todo} />
))}
</ul>
{todos.length === 0 && <p className="empty">Немає задач</p>}
</div>
);
}
export default App;
Middleware: розширення можливостей Redux
Middleware — це функція, що перехоплює actions між моментом виклику dispatch(action) та моментом надходження action до reducer. Це дозволяє додавати логування, асинхронні операції, затримки, валідацію тощо.
Redux Thunk: асинхронні actions
За замовчуванням Redux підтримує лише синхронні actions (звичайні об'єкти). Якщо потрібно виконати HTTP-запит, затримку чи іншу асинхронну операцію, потрібен middleware redux-thunk.
npm install redux-thunk
pnpm add redux-thunk
Налаштування store з middleware:
// src/store/index.ts
import { createStore, applyMiddleware } from "redux";
import thunk from "redux-thunk";
import rootReducer from "./reducers/rootReducer";
const store = createStore(
rootReducer,
applyMiddleware(thunk) // Додаємо middleware
);
export default store;
Thunk action creator — функція, що повертає функцію:
// src/store/actions/userActions.ts
import { Dispatch } from "redux";
import axios from "axios";
// Типи actions
export const USER_FETCH_START = "user/fetchStart";
export const USER_FETCH_SUCCESS = "user/fetchSuccess";
export const USER_FETCH_ERROR = "user/fetchError";
interface User {
id: number;
name: string;
email: string;
}
type UserAction =
| { type: typeof USER_FETCH_START }
| { type: typeof USER_FETCH_SUCCESS; payload: User }
| { type: typeof USER_FETCH_ERROR; payload: string };
// Thunk action creator — повертає функцію замість об'єкта
export function fetchUser(id: number) {
return async (dispatch: Dispatch<UserAction>) => {
dispatch({ type: USER_FETCH_START });
try {
const { data } = await axios.get<User>(
`https://jsonplaceholder.typicode.com/users/${id}`
);
dispatch({ type: USER_FETCH_SUCCESS, payload: data });
} catch (error) {
const message = error instanceof Error ? error.message : "Невідома помилка";
dispatch({ type: USER_FETCH_ERROR, payload: message });
}
};
}
Редюсер для обробки асинхронних станів:
// src/store/reducers/userReducer.ts
import { USER_FETCH_START, USER_FETCH_SUCCESS, USER_FETCH_ERROR } from "../actions/userActions";
interface User {
id: number;
name: string;
email: string;
}
interface UserState {
data: User | null;
isLoading: boolean;
error: string | null;
}
const initialState: UserState = {
data: null,
isLoading: false,
error: null,
};
export function userReducer(state = initialState, action: any): UserState {
switch (action.type) {
case USER_FETCH_START:
return { ...state, isLoading: true, error: null };
case USER_FETCH_SUCCESS:
return { data: action.payload, isLoading: false, error: null };
case USER_FETCH_ERROR:
return { ...state, isLoading: false, error: action.payload };
default:
return state;
}
}
Виклик thunk action у компоненті:
import { useEffect } from "react";
import { useDispatch, useSelector } from "react-redux";
import { fetchUser } from "../store/actions/userActions";
import { RootState } from "../store/reducers/rootReducer";
function UserProfile({ userId }: { userId: number }) {
const dispatch = useDispatch();
const { data: user, isLoading, error } = useSelector((state: RootState) => state.user);
useEffect(() => {
dispatch(fetchUser(userId) as any); // Thunk повертає функцію, не об'єкт
}, [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>{user.email}</p>
</div>
);
}
as any при диспетчі thunk action. Це технічне обмеження TypeScript у класичному Redux — dispatch очікує звичайний об'єкт action, але thunk повертає функцію. У Redux Toolkit це вирішено автоматично через типізований AppDispatch.Redux DevTools: time-travel debugging
Однією з найпотужніших переваг Redux є Redux DevTools — розширення для браузера, яке дозволяє:
- Переглядати всі відправлені actions у хронологічному порядку.
- Інспектувати стан до та після кожного action.
- Повертатися назад у часі до попередніх станів (time-travel debugging).
- Експортувати та імпортувати історію actions для відтворення багів.
Налаштування store для роботи з DevTools:
// src/store/index.ts
import { createStore, applyMiddleware, compose } from "redux";
import thunk from "redux-thunk";
import rootReducer from "./reducers/rootReducer";
// Типізація для window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__
declare global {
interface Window {
__REDUX_DEVTOOLS_EXTENSION_COMPOSE__?: typeof compose;
}
}
// Використання DevTools Compose, якщо доступний
const composeEnhancers = window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ || compose;
const store = createStore(
rootReducer,
composeEnhancers(applyMiddleware(thunk))
);
export default store;
Тепер відкрийте застосунок у браузері та перейдіть до вкладки Redux у DevTools. Кожен dispatch(action) буде записаний з повною історією трансформації стану.
useState, оскільки історія змін стану не зберігається. Redux, завдяки незмінності (immutability), автоматично створює «знімки» стану після кожного action.Структурування великих застосунків: Ducks pattern
У великих проєктах розділення actions, reducers та типів на окремі файли створює «стрибки» між файлами при додаванні нової функціональності. Ducks pattern пропонує групувати всю логіку однієї сутності в один файл:
// src/store/ducks/todos.ts — усе про задачі в одному місці
// === ТИПИ ===
export interface Todo {
id: number;
text: string;
completed: boolean;
}
export interface TodoState {
items: Todo[];
filter: "all" | "active" | "completed";
}
// === ACTION TYPES ===
const TODO_ADD = "todos/add";
const TODO_TOGGLE = "todos/toggle";
const TODO_DELETE = "todos/delete";
const TODO_SET_FILTER = "todos/setFilter";
// === ACTION CREATORS ===
let nextTodoId = 0;
export function addTodo(text: string) {
return { type: TODO_ADD, payload: text } as const;
}
export function toggleTodo(id: number) {
return { type: TODO_TOGGLE, payload: id } as const;
}
export function deleteTodo(id: number) {
return { type: TODO_DELETE, payload: id } as const;
}
export function setFilter(filter: TodoState["filter"]) {
return { type: TODO_SET_FILTER, payload: filter } as const;
}
type TodoAction =
| ReturnType<typeof addTodo>
| ReturnType<typeof toggleTodo>
| ReturnType<typeof deleteTodo>
| ReturnType<typeof setFilter>;
// === REDUCER ===
const initialState: TodoState = {
items: [],
filter: "all",
};
export function todoReducer(state = initialState, action: TodoAction): TodoState {
switch (action.type) {
case TODO_ADD:
return {
...state,
items: [
...state.items,
{ id: nextTodoId++, text: action.payload, completed: false },
],
};
case TODO_TOGGLE:
return {
...state,
items: state.items.map((todo) =>
todo.id === action.payload ? { ...todo, completed: !todo.completed } : todo
),
};
case TODO_DELETE:
return {
...state,
items: state.items.filter((todo) => todo.id !== action.payload),
};
case TODO_SET_FILTER:
return {
...state,
filter: action.payload,
};
default:
return state;
}
}
// === СЕЛЕКТОРИ (додатково) ===
export const selectAllTodos = (state: { todos: TodoState }) => state.todos.items;
export const selectVisibleTodos = (state: { todos: TodoState }) => {
const { items, filter } = state.todos;
switch (filter) {
case "active":
return items.filter((t) => !t.completed);
case "completed":
return items.filter((t) => t.completed);
default:
return items;
}
};
Тепер вся логіка задач згрупована в одному місці — легше розуміти, тестувати та підтримувати.
Redux Classic vs Context API: детальне порівняння
| Критерій | Context API | Redux (Classic) |
|---|---|---|
| Розмір бандлу | 0 КБ (вбудований у React) | ~2 КБ (redux) + ~5 КБ (react-redux) |
| Складність налаштування | Мінімальна (createContext + Provider) | Середня (store, reducers, actions, middleware) |
| Типізація TypeScript | Ручна для кожного контексту | Централізована через RootState |
| Оптимізація перерендерів | Немає (всі споживачі оновлюються разом) | Автоматична (useSelector оптимізує підписки) |
| DevTools | Немає (лише React DevTools для Context) | Redux DevTools із time-travel debugging |
| Middleware для сайд-ефектів | Немає (логіка у компонентах або useEffect) | Вбудована підтримка (redux-thunk, redux-saga) |
| Історія змін | Немає | Повна історія actions у DevTools |
| Тестування логіки стану | Логіка розкидана по компонентах | Редюсери — чисті функції, легко тестуються |
| Масштабованість | Проблеми при 10+ контекстах | Лінійна масштабованість (один store, багато reducers) |
| Серіалізація стану | Ручна (JSON.stringify у useEffect) | Автоматична (middleware redux-persist) |
Best Practices класичного Redux
✅ Незмінність стану (Immutability)
state напряму у редюсері. Завжди повертайте новий об'єкт через spread-оператор ({ ...state, field: newValue }) або .map(), .filter() для масивів.✅ Нормалізація стану
{ users: [{ id: 1, posts: [...] }] }). Зберігайте дані у плоскому вигляді: { users: { byId: { 1: {...} }, allIds: [1, 2] }, posts: { byId: {...}, allIds: [...] } }.✅ Селектори для обчислень
reselect (або Redux Toolkit createSelector), щоб уникнути повторних обчислень при кожному рендері.✅ Дрібні редюсери
combineReducers для розбиття логіки.⚠️ Не зберігайте у Redux
useState для таких випадків.⚠️ Типізація actions
as const у action creators або discriminated unions для type-safe редюсерів. Це дозволить TypeScript автоматично звужувати тип action.payload у switch-блоках.Питання для самоконтролю
Чиста функція — це функція, яка:
- Не має побічних ефектів (не змінює зовнішні змінні, не викликає API, не генерує випадкові числа).
- Детермінована: однаковий вхід завжди дає однаковий вихід.
Якщо редюсер містить побічні ефекти (наприклад, fetch() або Math.random()), результат виконання стає непередбачуваним. Redux покладається на те, що редюсер можна викликати багато разів із тими самими аргументами (наприклад, при time-travel debugging у DevTools), і результат завжди буде однаковий.
Приклад порушення:
// ❌ Неправильно — побічний ефект
function counterReducer(state = 0, action) {
if (action.type === "INCREMENT_RANDOM") {
return state + Math.random(); // Непередбачуваний результат!
}
return state;
}
Правильний підхід:
// ✅ Правильно — випадкове число у payload action
dispatch({ type: "INCREMENT_RANDOM", payload: Math.random() });
function counterReducer(state = 0, action) {
if (action.type === "INCREMENT_RANDOM") {
return state + action.payload; // Детерміновано
}
return state;
}
Context API передає значення через value проп <Context.Provider value={...}>. Коли це значення змінюється (навіть якщо змінилася лише одна властивість об'єкта), всі споживачі цього контексту перерендеряться, навіть якщо вони не використовують змінену частину.
Приклад:
const AppContext = createContext({ user: null, cart: [] });
// Компонент UserProfile читає лише user
function UserProfile() {
const { user } = useContext(AppContext);
return <div>{user.name}</div>;
}
// Компонент CartBadge читає лише cart
function CartBadge() {
const { cart } = useContext(AppContext);
return <span>{cart.length}</span>;
}
// При зміні cart обидва компоненти перерендеряться!
Redux (через react-redux) використовує підписки на рівні окремих селекторів. useSelector порівнює результат селектора з попереднім значенням і рендерить компонент лише якщо конкретне значення змінилося:
// Компонент UserProfile підписаний лише на state.user
const user = useSelector((state) => state.user);
// Компонент CartBadge підписаний лише на state.cart.length
const cartCount = useSelector((state) => state.cart.items.length);
// При зміні cart.items лише CartBadge перерендериться!
Middleware — це функція, що перехоплює actions між моментом виклику dispatch(action) та моментом передачі action до reducer. Це дозволяє додавати глобальну логіку без зміни редюсерів чи компонентів:
Типові випадки використання:
- Логування: записувати всі actions та зміни стану у консоль.
- Асинхронні операції: redux-thunk дозволяє диспатчити функції замість об'єктів.
- Аналітика: відправляти події у Google Analytics при певних actions.
- Валідація: перевіряти структуру actions перед передачею до reducer.
Приклад простого логуючого middleware:
const loggerMiddleware = (store) => (next) => (action) => {
console.log("Dispatching:", action);
const result = next(action); // Передаємо action далі
console.log("New state:", store.getState());
return result;
};
const store = createStore(rootReducer, applyMiddleware(loggerMiddleware));
Практичні завдання
Створіть Redux-додаток для управління списком книг із наступними вимогами:
Моделі даних:
interface Book {
id: number;
title: string;
author: string;
year: number;
genre: "fiction" | "science" | "history" | "biography";
price: number;
}
Функціональність:
- Додавання нової книги через форму
- Видалення книги зі списку
- Фільтрація за жанром (випадаючий список)
- Статистика:
- Загальна кількість книг
- Середня ціна
- Найдорожча книга
- Сортування за ціною (зростання/спадання)
Структура стану:
interface BookstoreState {
books: Book[];
filter: "all" | "fiction" | "science" | "history" | "biography";
sortOrder: "asc" | "desc";
}
Критерії успіху:
- Редюсер написаний без мутацій (immutable updates)
- Використано
combineReducers, якщо логіка розділена - Статистика обчислюється через селектори (не зберігається у стані)
- Компоненти використовують
useSelectorтаuseDispatch
// src/store/types.ts
export interface Book {
id: number;
title: string;
author: string;
year: number;
genre: "fiction" | "science" | "history" | "biography";
price: number;
}
export interface BookstoreState {
books: Book[];
filter: "all" | "fiction" | "science" | "history" | "biography";
sortOrder: "asc" | "desc";
}
// src/store/actions/bookActions.ts
export const BOOK_ADD = "books/add";
export const BOOK_DELETE = "books/delete";
export const BOOK_SET_FILTER = "books/setFilter";
export const BOOK_SET_SORT = "books/setSort";
import { Book, BookstoreState } from "../types";
export type BookAction =
| { type: typeof BOOK_ADD; payload: Book }
| { type: typeof BOOK_DELETE; payload: number }
| { type: typeof BOOK_SET_FILTER; payload: BookstoreState["filter"] }
| { type: typeof BOOK_SET_SORT; payload: BookstoreState["sortOrder"] };
let nextBookId = 1;
export function addBook(book: Omit<Book, "id">): BookAction {
return {
type: BOOK_ADD,
payload: { ...book, id: nextBookId++ },
};
}
export function deleteBook(id: number): BookAction {
return { type: BOOK_DELETE, payload: id };
}
export function setFilter(filter: BookstoreState["filter"]): BookAction {
return { type: BOOK_SET_FILTER, payload: filter };
}
export function setSortOrder(order: BookstoreState["sortOrder"]): BookAction {
return { type: BOOK_SET_SORT, payload: order };
}
// src/store/reducers/bookReducer.ts
import { BOOK_ADD, BOOK_DELETE, BOOK_SET_FILTER, BOOK_SET_SORT, BookAction } from "../actions/bookActions";
import { BookstoreState } from "../types";
const initialState: BookstoreState = {
books: [],
filter: "all",
sortOrder: "asc",
};
export function bookReducer(state = initialState, action: BookAction): BookstoreState {
switch (action.type) {
case BOOK_ADD:
return {
...state,
books: [...state.books, action.payload],
};
case BOOK_DELETE:
return {
...state,
books: state.books.filter((book) => book.id !== action.payload),
};
case BOOK_SET_FILTER:
return {
...state,
filter: action.payload,
};
case BOOK_SET_SORT:
return {
...state,
sortOrder: action.payload,
};
default:
return state;
}
}
// src/store/selectors/bookSelectors.ts
import { createSelector } from "reselect"; // npm install reselect
import { BookstoreState } from "../types";
const selectBooks = (state: { books: BookstoreState }) => state.books.books;
const selectFilter = (state: { books: BookstoreState }) => state.books.filter;
const selectSortOrder = (state: { books: BookstoreState }) => state.books.sortOrder;
export const selectFilteredAndSortedBooks = createSelector(
[selectBooks, selectFilter, selectSortOrder],
(books, filter, sortOrder) => {
// Фільтрація
let filtered = filter === "all" ? books : books.filter((b) => b.genre === filter);
// Сортування
return [...filtered].sort((a, b) =>
sortOrder === "asc" ? a.price - b.price : b.price - a.price
);
}
);
export const selectBookStats = createSelector([selectBooks], (books) => {
if (books.length === 0) {
return { total: 0, avgPrice: 0, maxPrice: 0 };
}
const total = books.length;
const avgPrice = books.reduce((sum, b) => sum + b.price, 0) / total;
const maxPrice = Math.max(...books.map((b) => b.price));
return { total, avgPrice, maxPrice };
});
// src/components/BookList.tsx
import { useSelector, useDispatch } from "react-redux";
import { selectFilteredAndSortedBooks } from "../store/selectors/bookSelectors";
import { deleteBook } from "../store/actions/bookActions";
function BookList() {
const books = useSelector(selectFilteredAndSortedBooks);
const dispatch = useDispatch();
return (
<ul className="book-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>
);
}
export default BookList;
Ключові моменти реалізації:
- Селектори з
reselect—selectFilteredAndSortedBooksобчислює фільтрацію та сортування лише при зміні залежностей (books, filter, sortOrder). Безreselectці обчислення повторювалися б при кожному рендері компонента. - Immutable updates —
[...filtered].sort()створює копію масиву перед сортуванням, щоб не мутувати оригінальний масив. - Типізація actions — union-тип
BookActionдозволяє TypeScript автоматично звужувати типaction.payloadуswitch-блоках.
Підсумок
✅ Опановані концепції
- Фундаментальні принципи Redux: single source of truth, read-only state, pure functions
- Actions, reducers, store та однонаправлений потік даних
- Інтеграція з React через react-redux (Provider, useSelector, useDispatch)
- Асинхронні операції через redux-thunk middleware
- Налагодження через Redux DevTools із time-travel debugging
🎯 Наступні кроки
createSlice, createAsyncThunk та вбудовану підтримку Immer.Axios: HTTP-клієнт із розширеними можливостями
Опанування Axios як альтернативи fetch API: типізовані запити, interceptors, обробка помилок, скасування та кешування запитів у React із TypeScript
Redux Toolkit: сучасний стандарт Redux
Опанування Redux Toolkit (RTK): createSlice, configureStore, createAsyncThunk, RTK Query, усунення boilerplate та інтеграція Immer для безпечних мутацій стану