Skip to main content

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-обмеження.

Швидкий приклад

javascript
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 ReduxRedux Toolkit
Налаштування редюсераРучний switch + спред операториcreateSlice + Immer-мутації
Конфігурація сховищаcreateStore + ручний middlewareconfigureStore (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

javascript
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 не генерує. Ця різниця часто вводить людей в оману на співбесідах.

Типові помилки

Мутація стану поза слайсом

javascript
// Неправильно: пряма мутація у звичайному редюсері const reducer = (state = { list: [] }, action) => { state.list.push(action.payload); // Ламає виявлення змін у Redux return state; };

Immer захищає тебе тільки всередині createSlice. useSelector порівнює посилання на стан. Якщо мутуєш існуючий об'єкт і повертаєш його, посилання не змінюється і компоненти не перерендерюються. Рішення: переноси логіку в createSlice.

Відсутня обробка в extraReducers для async thunk

javascript
// Неправильно: thunk є, але слайс його ігнорує const slice = createSlice({ name: 'todos', initialState: { items: [], status: 'idle' }, reducers: {}, extraReducers: {} // Порожньо - статус залишається 'idle' назавжди });

Якщо диспатчиш fetchTodos(), але немає addCase(fetchTodos.fulfilled, ...), стан ніколи не оновиться. Використовуй builder-патерн і обробляй усі три стани.

Порожній reducer map у configureStore

javascript
// Неправильно 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

Однаковий результат, зовсім різний обсяг коду.

javascript
// 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, відстеження стану запиту, локальне перемикання.

javascript
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.

javascript
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, у стані залишаться обидва записи одночасно.

Коротка відповідь

Для співбесіди
Premium

Коротка відповідь допоможе вам впевнено відповідати на цю тему під час співбесіди.

Дочитали статтю?