React Typescript

Redux Toolkit: сучасний стандарт Redux

Опанування Redux Toolkit (RTK): createSlice, configureStore, createAsyncThunk, RTK Query, усунення boilerplate та інтеграція Immer для безпечних мутацій стану

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/rejected actions.

Проблема класичного Redux: ocean of boilerplate

У попередній темі ми створили Redux-додаток класичним способом. Для простого лічильника довелося написати:

  1. Константи типів actions (const INCREMENT = "counter/increment")
  2. Action creators — функції, що генерують action-об'єкти
  3. Reducer із switch/case та ручним spread-синтаксисом для immutability
  4. Типізацію discriminated unions для кожного action
  5. CombineReducers для об'єднання reducers
  6. 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 для одного reducer: ~70–100 рядків (actions, types, reducer, immutable updates).
  • Redux Toolkit для одного slice: ~15–20 рядків (reducer-методи з «мутабельним» синтаксисом).
У проєкті з 10 сутностями різниця становить 700+ рядків boilerplate, які RTK генерує автоматично.

Встановлення та налаштування Redux Toolkit

npm install @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;
Як працює «мутабельний» синтаксис? Immer перехоплює всі операції зміни стану (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],
  };
}
Обмеження Immer:
  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 };
}
  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: детальне порівняння

КритерійКласичний ReduxRedux Toolkit
Обсяг boilerplate~70–100 рядків на reducer~15–20 рядків на slice
Action creatorsРучне написання функційГенеруються автоматично createSlice
Immutable updatesРучний spread-синтаксис ({ ...state })«Мутабельний» синтаксис через Immer
Async logicredux-thunk + ручні thunk-функціїcreateAsyncThunk із автоматичними pending/fulfilled
Store configurationcreateStore + applyMiddleware + DevToolsconfigureStore — все автоматично
Типізація TypeScriptРучна для actions, reducers, stateАвтоматична у 90% випадків
combineReducersРучний викликАвтоматично у configureStore
DevToolsРучне підключення composeEnhancersАвтоматично
MiddlewareРучне додавання applyMiddlewaregetDefaultMiddleware().concat(...)
Мемоїзовані селекториПотрібен окремий npm install reselectВбудований createSelector
Перевірки immutability у devНемаєАвтоматично увімкнені
Кількість залежностейredux + react-redux + redux-thunk + reselect@reduxjs/toolkit + react-redux
Міграція з класичного Redux на RTK: RTK повністю сумісний із класичним Redux. Можна міґрувати поступово — замінювати окремі reducers на slices, зберігаючи старі reducers у тому самому store. Це дозволяє оновлювати великі проєкти без повного переписування.

Best Practices Redux Toolkit

✅ Один slice = одна логічна сутність

Не створюйте «божественні slices» на сотні рядків. Кожен slice має відповідати за одну сутність (users, posts, cart) та не перевищувати 100–150 рядків reducers.

✅ Селектори поза slice

Складні селектори з createSelector виносьте у окремий файл (selectors/) або принаймні поза межі slice. Це спрощує тестування та повторне використання.

✅ Нормалізація стану

Для великих колекцій використовуйте нормалізовану структуру: { byId: { 1: {...}, 2: {...} }, allIds: [1, 2] }. RTK надає утиліту createEntityAdapter для автоматичної нормалізації.

✅ Типізовані хуки у окремому файлі

Створіть store/hooks.ts з useAppDispatch та useAppSelector один раз для всього проєкту. Імпортуйте їх замість оригінальних хуків react-redux.

⚠️ Не мутуйте первинні типи

Immer працює лише з об'єктами та масивами. Для стану-числа (initialState: 0) використовуйте return newValue, а не state += 1.

⚠️ Не комбінуйте мутації та return

У reducer або мутуйте стан через Immer, або повертайте новий стан. Змішування призводить до непередбачуваної поведінки.

Питання для самоконтролю


Практичне завдання


Підсумок

✅ Опановані навички

  • Створення slices через createSlice із автоматичною генерацією actions
  • Асинхронні операції через createAsyncThunk без ручних thunk-функцій
  • «Мутабельний» синтаксис через Immer для простішого коду
  • Типізовані хуки useAppDispatch та useAppSelector
  • Мемоїзовані селектори через createSelector

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

Redux Toolkit спрощує управління станом, але для складних серверних операцій (кешування, інвалідація, оптимістичні оновлення) існує RTK Query — офіційна бібліотека data fetching поверх RTK, яку розглянемо у темі 39.
Copyright © 2026