React Typescript

Redux: класичний підхід до глобального стану

Опанування класичного Redux від основ до інтеграції з React: actions, reducers, store, middleware, react-redux хуки та порівняння з Context API

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>
  );
}
Обирайте Context API, якщо:
  • Стан простий (2–3 поля без складних обчислень).
  • Оновлення рідкісні (тема застосунку, налаштування локалізації).
  • Проєкт невеликий (до 10–15 компонентів, що споживають контекст).
Обирайте Redux, якщо:
  • Стан складний (вкладені об'єкти, масиви, залежності між даними).
  • Потрібна історія змін для відладки (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>
Зверніть увагу на однонаправлений потік даних (unidirectional data flow): події DOM → 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;
}
Мутація стану у редюсері призводить до непомітних багів: React не помітить зміни (посилання на об'єкт залишилося тим самим), Redux DevTools не зафіксує історію, а 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));
Конвенція іменування actions: використовуйте формат 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 надає два ключові інструменти:

  1. <Provider store={store}> — компонент, що робить store доступним для всього дерева React.
  2. Хуки useSelector та useDispatch — сучасний спосіб читання стану та відправки actions.
npm install react-redux
npm install --save-dev @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

Налаштування 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) буде записаний з повною історією трансформації стану.

Time-travel debugging дозволяє «перемотати» застосунок до будь-якого моменту у минулому без перезавантаження сторінки. Це неможливо у звичайному React із 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 APIRedux (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)
Коли Context API достатньо: теми застосунку, налаштування мови інтерфейсу, дані автентифікації (токен, профіль користувача), модальні діалоги. Це прості дані, що змінюються рідко та споживаються багатьма компонентами.Коли потрібен Redux: e-commerce (кошик, фільтри, пагінація, історія замовлень), соціальні мережі (стрічка новин, коментарі, лайки), dashboard із складною фільтрацією та реалтайм-оновленнями, будь-який застосунок із десятками взаємопов'язаних сутностей.

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), щоб уникнути повторних обчислень при кожному рендері.

✅ Дрібні редюсери

Один редюсер = одна логічна сутність. Уникайте «божественних редюсерів» на 500+ рядків. Використовуйте combineReducers для розбиття логіки.

⚠️ Не зберігайте у Redux

Не зберігайте у Redux: локальний стан форм (контрольовані поля), UI-стан модальних вікон (відкрито/закрито), дані, що не потребують синхронізації між компонентами. Використовуйте useState для таких випадків.

⚠️ Типізація actions

Використовуйте as const у action creators або discriminated unions для type-safe редюсерів. Це дозволить 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

🎯 Наступні кроки

Класичний Redux закладає фундаментальне розуміння управління станом, але вимагає багато boilerplate-коду. Наступна тема — Redux Toolkit (RTK) — сучасна офіційна бібліотека, яка спрощує Redux через createSlice, createAsyncThunk та вбудовану підтримку Immer.
Copyright © 2026