Redux toolkit
Redux Toolkit (RTK) - офіційний набір інструментів від команди Redux, який скорочує шаблонний код приблизно на 80% завдяки Immer-редюсерам, автоматичному генеруванню action creators і готовому налаштуванню сховища.
Теорія
TL;DR
- Vanilla Redux означає писати кожен тип дії, switch-case і спред оператор вручну. RTK генерує все це з одного конфігу
createSlice. - Головна різниця:
createSlice+ Immer дозволяє писатиstate.value += 1напряму, замістьreturn { ...state, value: state.value + 1 }. configureStoreпоставляється з Redux Thunk і DevTools увімкненими за замовчуванням.createAsyncThunkавтоматично генерує типи дійpending/fulfilled/rejectedдля будь-якої async операції.- Правило: RTK для всіх нових Redux-проєктів. Vanilla Redux тільки якщо є legacy-обмеження.
Швидкий приклад
import { createSlice, configureStore } from '@reduxjs/toolkit';
const counterSlice = createSlice({
name: 'counter',
initialState: { value: 0 },
reducers: {
increment: (state) => { state.value += 1; }, // Immer сам дбає про іммутабельність
decrement: (state) => { state.value -= 1; },
incrementByAmount: (state, action) => { state.value += action.payload; }
}
});
export const { increment, decrement, incrementByAmount } = counterSlice.actions;
const store = configureStore({
reducer: { counter: counterSlice.reducer }
});Один виклик createSlice замінює 40+ рядків vanilla Redux: жодних констант ACTION_TYPE, жодних switch-case, жодних спред-операторів. configureStore автоматично підключає Thunk middleware і Redux DevTools.
Головна різниця від vanilla Redux
У vanilla Redux редюсери повинні явно повертати новий об'єкт стану: { ...state, value: state.value + 1 }. RTK загортає редюсери у функцію produce від Immer, яка створює Proxy навколо чорнетки стану (draft). Ти мутуєш чорнетку, Immer відстежує які шляхи змінились і будує новий іммутабельний об'єкт через структурне копіювання. Тільки змінені частини копіюються, все інше залишає спільні посилання. Результат ідентичний написаному вручну іммутабельному коду, але синтаксис значно простіший.
Коли використовувати
- Маленький застосунок з локальним UI-станом: Redux взагалі не потрібен. Zustand або Context достатньо.
- Середній React-застосунок зі спільними async-даними: RTK зі слайсами і
createAsyncThunk. - Складний застосунок з нормалізованими entities (користувачі, пости): RTK разом з
createEntityAdapter. - Legacy Redux кодобаза: поступово загортай існуючі редюсери в
createSlice, замінюйcreateStoreнаconfigureStore. - Проєкти без React: утиліти RTK працюють у звичайному JS і Node.js.
Порівняльна таблиця
| Функціональність | Vanilla Redux | Redux Toolkit |
|---|---|---|
| Налаштування редюсера | Ручний switch + спред оператори | createSlice + Immer-мутації |
| Конфігурація сховища | createStore + ручний middleware | configureStore (Thunk + DevTools за замовчуванням) |
| Async логіка | Ручний thunk-шаблон | createAsyncThunk (авто pending/fulfilled/rejected) |
| Управління entities | Ручна нормалізація | createEntityAdapter з вбудованими CRUD операціями |
| Action creators | Пишуться вручну | Автогенеруються з createSlice |
| Розмір бандлу | ~2KB ядро | ~10KB стиснутих (включає Immer + Thunk) |
| Коли використовувати | Legacy, власні middleware | Усі нові production застосунки (офіційна рекомендація) |
Як Immer працює всередині RTK
Коли createSlice запускає твій редюсер, він загортає його у produce від Immer. Immer створює Proxy (чорнетку) над поточним станом. Будь-яка мутація, наприклад state.items.push(item), перехоплюється цим Proxy і записується. Після виконання редюсера Immer обходить записані зміни і будує новий об'єкт стану: тільки змінені шляхи отримують нові посилання, незмінені залишаються спільними. Саме тому state.value += 1 всередині слайсу безпечний, а та сама мутація поза Immer зламала б виявлення змін у Redux.
configureStore автоматично підключає applyMiddleware(thunk) і enhancer DevTools. Додатковий middleware передається через опцію middleware.
Async операції з createAsyncThunk
import { createAsyncThunk, createSlice } from '@reduxjs/toolkit';
export const fetchTodos = createAsyncThunk('todos/fetch', async () => {
const res = await fetch('https://jsonplaceholder.typicode.com/todos?_limit=5');
return res.json(); // Стає action.payload у fulfilled
});
const todosSlice = createSlice({
name: 'todos',
initialState: { items: [], status: 'idle' },
reducers: {
toggleTodo: (state, action) => {
state.items[action.payload].completed = !state.items[action.payload].completed;
}
},
extraReducers: (builder) => {
builder
.addCase(fetchTodos.pending, (state) => { state.status = 'loading'; })
.addCase(fetchTodos.fulfilled, (state, action) => {
state.items = action.payload;
state.status = 'succeeded';
})
.addCase(fetchTodos.rejected, (state) => { state.status = 'failed'; });
}
});extraReducers обробляє типи дій, що надходять ззовні слайсу, наприклад відповіді createAsyncThunk. Поле reducers у createSlice автоматично генерує action creators. extraReducers не генерує. Ця різниця часто вводить людей в оману на співбесідах.
Типові помилки
Мутація стану поза слайсом
// Неправильно: пряма мутація у звичайному редюсері
const reducer = (state = { list: [] }, action) => {
state.list.push(action.payload); // Ламає виявлення змін у Redux
return state;
};Immer захищає тебе тільки всередині createSlice. useSelector порівнює посилання на стан. Якщо мутуєш існуючий об'єкт і повертаєш його, посилання не змінюється і компоненти не перерендерюються. Рішення: переноси логіку в createSlice.
Відсутня обробка в extraReducers для async thunk
// Неправильно: thunk є, але слайс його ігнорує
const slice = createSlice({
name: 'todos',
initialState: { items: [], status: 'idle' },
reducers: {},
extraReducers: {} // Порожньо - статус залишається 'idle' назавжди
});Якщо диспатчиш fetchTodos(), але немає addCase(fetchTodos.fulfilled, ...), стан ніколи не оновиться. Використовуй builder-патерн і обробляй усі три стани.
Порожній reducer map у configureStore
// Неправильно
configureStore({ reducer: {} }); // Сховище є, але нічого не зберігаєПередавай реальні редюсери: reducer: { counter: counterSlice.reducer }.
Немає обробки rejected thunk
Якщо запит завершується помилкою, а в тебе немає .addCase(fetchTodos.rejected, ...), застосунок мовчки залишається у стані loading. У production завжди обробляй rejected хоча б оновленням статусу.
Забутий upsertOne після оптимістичного оновлення
Якщо додав пост оптимістично з тимчасовим ID, а потім прийшла відповідь від сервера, потрібен postsAdapter.upsertOne(state, action.payload), щоб замінити тимчасовий запис. Без цього в стані опиняться обидва: і тимчасовий, і реальний.
Де зустрічається у production
- React/Next.js дашборди: окремі слайси для користувачів, замовлень, продуктів. Кожна фіча - окремий файл слайсу.
- RTK Query: кешування API-запитів у застосунках, що вже використовують Redux. Замінює ручний fetch + thunk + стан завантаження без додавання другої бібліотеки.
- Нормалізовані стрічки:
createEntityAdapterдля Twitter-подібних feeds, де один об'єкт користувача зустрічається в багатьох місцях стану. - SSR у Next.js: гідратація сховища на стороні сервера в
getServerSideProps, стан слайсу серіалізується у JSON і передається клієнту. - Міграція: більшість команд спочатку замінюють
createStoreнаconfigureStore, потім поступово конвертують редюсери у слайси фіча за фічею.
Питання для поглиблення
Q: Що таке Immer і навіщо RTK його використовує?
A: Immer загортає редюсер у Proxy, який перехоплює мутації. Після виконання редюсера Immer будує новий іммутабельний стан через структурне копіювання. RTK використовує його, щоб можна було писати state.value += 1 замість return { ...state, value: state.value + 1 }.
Q: Яка різниця між reducers і extraReducers у createSlice?
A: reducers створює і логіку редюсера, і action creators автоматично. extraReducers додає логіку редюсера для дій ззовні слайсу, наприклад thunk-ів або дій з інших слайсів. Action creators у extraReducers не генеруються.
Q: Чим createAsyncThunk відрізняється від написаного вручну thunk?
A: Він автоматично генерує три типи дій: pending, fulfilled і rejected. Також загортає async-функцію у try/catch. При ручному thunk ти сам диспатчиш кожен із цих станів і обробляєш помилки явно.
Q: Що таке createEntityAdapter і коли він корисний?
A: Він перетворює масив на кшталт [{id: 1, name: 'Alice'}] у нормалізовану структуру { ids: [1], entities: { 1: {id: 1, name: 'Alice'} } } і надає CRUD-операції: addOne, upsertOne, removeOne. Допомагає, коли одна і та сама entity зустрічається в різних частинах стану.
Q: (Senior) Як працює інвалідація тегів у RTK Query для управління кешем?
A: Кожен query endpoint визначає providesTags, кожна мутація визначає invalidatesTags. Після успішного виконання мутації RTK Query перевіряє, які закешовані запити мають спільні теги, і автоматично їх повторює. Для оптимістичних оновлень використовуй patchQueryData до відповіді API і скасовуй патч при відхиленні мутації.
Приклади
Лічильник: RTK проти vanilla Redux
Однаковий результат, зовсім різний обсяг коду.
// RTK: повноцінний слайс у ~10 рядків
import { createSlice } from '@reduxjs/toolkit';
const counterSlice = createSlice({
name: 'counter',
initialState: { value: 0 },
reducers: {
increment: (state) => { state.value += 1; }
}
});
console.log(
counterSlice.reducer(undefined, counterSlice.actions.increment())
); // { value: 1 }Vanilla Redux потребує: константу типу дії, окрему функцію action creator, редюсер зі switch/case і спред для уникнення мутації. RTK збирає все чотири у поле reducers.
Todo-застосунок з async fetch
Реалістичний патерн: завантаження даних з API, відстеження стану запиту, локальне перемикання.
import { createSlice, createAsyncThunk, configureStore } from '@reduxjs/toolkit';
export const fetchTodos = createAsyncThunk('todos/fetch', async () => {
const res = await fetch('https://jsonplaceholder.typicode.com/todos?_limit=5');
return res.json();
});
const todosSlice = createSlice({
name: 'todos',
initialState: { items: [], status: 'idle' },
reducers: {
toggleTodo: (state, action) => {
const todo = state.items[action.payload];
if (todo) todo.completed = !todo.completed; // Безпечна Immer-мутація
}
},
extraReducers: (builder) => {
builder
.addCase(fetchTodos.pending, (state) => { state.status = 'loading'; })
.addCase(fetchTodos.fulfilled, (state, action) => {
state.items = action.payload;
state.status = 'succeeded';
})
.addCase(fetchTodos.rejected, (state) => { state.status = 'failed'; });
}
});
const store = configureStore({
reducer: { todos: todosSlice.reducer }
});
// У React-компоненті:
// const { items, status } = useSelector((state) => state.todos);
// dispatch(fetchTodos()); // Запускає цикл pending -> fulfilledПісля виконання fetchTodos() статус стає 'succeeded' і items містить отримані дані. toggleTodo працює без спред-операторів, бо Immer відстежує мутацію автоматично.
Entity adapter з оптимістичним оновленням
Цей патерн часто ставить у глухий кут навіть досвідчених розробників. Головна деталь: upsertOne після відповіді API.
import { createSlice, createEntityAdapter, createAsyncThunk } from '@reduxjs/toolkit';
const postsAdapter = createEntityAdapter();
// Результат: { ids: [], entities: {} }
export const addNewPost = createAsyncThunk('posts/add', async (postData) => {
const res = await fetch('/api/posts', {
method: 'POST',
body: JSON.stringify(postData)
});
return res.json(); // Повертає пост з реальним ID від сервера
});
const postsSlice = createSlice({
name: 'posts',
initialState: postsAdapter.getInitialState(),
reducers: {},
extraReducers: (builder) => {
builder
.addCase(addNewPost.pending, (state, action) => {
// Оптимістично: додаємо локально з тимчасовим ID до відповіді API
postsAdapter.addOne(state, { id: 'temp-' + Date.now(), ...action.meta.arg });
})
.addCase(addNewPost.fulfilled, (state, action) => {
// Замінюємо тимчасовий запис реальним
// upsertOne знаходить за id - без нього temp дублює реальний запис
postsAdapter.upsertOne(state, action.payload);
})
.addCase(addNewPost.rejected, (state) => {
// Прибираємо оптимістичний запис при помилці
const tempId = Object.keys(state.entities).find((id) => id.startsWith('temp-'));
if (tempId) postsAdapter.removeOne(state, tempId);
});
}
});
// Селектори йдуть у комплекті:
// postsAdapter.getSelectors((state) => state.posts).selectAll(store.getState())Кейс pending додає пост миттєво, щоб UI відчувався швидким. Кейс fulfilled викликає upsertOne, який знаходить наявний запис за id і замінює його, або вставляє новий якщо збігу немає. Якщо замість upsertOne використати addOne, у стані залишаться обидва записи одночасно.
Коротка відповідь
Для співбесідиКоротка відповідь допоможе вам впевнено відповідати на цю тему під час співбесіди.