Запропонувати правкуПокращити цю статтюДопрацюйте відповідь до «Redux toolkit». Ваші зміни проходять модерацію перед публікацією.Потрібне підтвердженняКонтентЩо ви змінюєте🇺🇸EN🇺🇦UAПереглядЗаголовок (UA)Коротка відповідь (UA)**Redux Toolkit** - офіційний набір інструментів для Redux зі значно меншим шаблонним кодом. `createSlice` об'єднує action creators і Immer-редюсери в одному виклику. `configureStore` автоматично підключає Thunk і DevTools. ```javascript const counterSlice = createSlice({ name: 'counter', initialState: { value: 0 }, reducers: { increment: (state) => { state.value += 1 } // Спреди не потрібні } }); ``` **Головне:** пишеш `state.value += 1` замість `return { ...state, value: state.value + 1 }`.Показується над повною відповіддю для швидкого нагадування.Відповідь (UA)Зображення**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 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 ```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`, у стані залишаться обидва записи одночасно.Для рев’юераПримітка для модератора (необов’язково)Бачить лише модератор. Прискорює рев’ю.