Що таке Redux?
Redux - це передбачуваний контейнер стану для JavaScript-додатків, який централізує всі зміни через чисті reducer-функції, що реагують на прості action-об'єкти.
Теорія
TL;DR
- Redux схожий на єдину бухгалтерську книгу компанії: кожна зміна (action) записується бухгалтерами (reducers), які створюють нову сторінку (state), не стираючи попередню
- Один глобальний store замість розкиданого локального стану в компонентах
- Три основні частини: store (тримає стан), actions (описують події), reducers (чисті функції оновлення)
- Використовуй, якщо логіка стану повторюється в 5+ компонентах або потрібне time-travel debugging
- Redux Toolkit - сучасний підхід до написання Redux, прибирає приблизно 70% шаблонного коду
Швидкий приклад
import { createStore } from 'redux';
const reducer = (state = [], action) => {
switch (action.type) {
case 'ADD_TODO': return [...state, action.payload]; // Незмінне оновлення
default: return state;
}
};
const store = createStore(reducer);
store.dispatch({ type: 'ADD_TODO', payload: 'Купити молоко' });
console.log(store.getState()); // ['Купити молоко']
store.dispatch({ type: 'ADD_TODO', payload: 'Вигуляти собаку' });
console.log(store.getState()); // ['Купити молоко', 'Вигуляти собаку']Action описує що сталось. Reducer вирішує що це означає для стану. Store тримає результат.
Головна різниця
Локальний стан компонентів розкиданий по дереву. Кожен компонент, якому потрібні спільні дані, отримує їх через props або Context, і це швидко стає хаосом. Redux кладе все в одне дерево об'єктів, а єдиний спосіб щось змінити - відправити action. Це одне обмеження і робить великі додатки зручними для debug.
Коли використовувати Redux
- Малий додаток, менше 5 компонентів зі спільним станом - не варто. React Context або useState достатньо.
- Середній додаток з повторюваною логікою стану - Redux Toolkit зі slices.
- Великий додаток з devtools або time-travel debugging - повний Redux з middleware.
- Server-side або не-React JS додаток - Redux core без React-прив'язок.
- Стан менше 10KB і простий - розглянь Zustand, він легший.
Як Redux працює всередині
Коли викликаєш store.dispatch(action), Redux пропускає action через middleware (thunks, логери), потім передає кореневому reducer. Кореневий reducer через combineReducers викликає кожен дочірній reducer. Кожен повертає новий фрагмент стану. Redux збирає фрагменти в новий об'єкт стану і повідомляє підписників. React's useSelector порівнює новий стан з попереднім за посиланням і перерендерює тільки ті компоненти, чий вибраний фрагмент реально змінився.
Чисті функції роблять це швидким. Незмінні оновлення дозволяють React.memo і reselect пропускати непотрібні рендери через просту перевірку посилання.
Типові помилки
Мутація стану в reducer
// Неправильно - мутує спільний масив
function badReducer(state = [], action) {
if (action.type === 'ADD') state.push(action.payload); // Пряма мутація!
return state;
}
// Правильно - повертає новий масив
function goodReducer(state = [], action) {
if (action.type === 'ADD') return [...state, action.payload];
return state;
}Redux ділить посилання на стан між рендерами. Якщо мутуєш, всі підписники бачать зміну одразу, минаючи цикл reducer - і вся модель ламається.
Dispatch всередині render
// Неправильно - виконується при кожному рендері
function BadComponent() {
dispatch(fetchData()); // Безкінечний цикл!
return <div>...</div>;
}
// Правильно - виконується один раз при монтуванні
function GoodComponent() {
useEffect(() => { dispatch(fetchData()); }, []);
return <div>...</div>;
}Це одна з найпоширеніших Redux-помилок у продакшені. Я бачив, як вона викликала сотні дублікатів API-запитів за одну сесію, перш ніж хтось це помітив.
Ігнорування Redux Toolkit
Писати vanilla Redux з createStore і combineReducers вручну в 2024 - це багато шаблонного коду без жодної користі. Toolkit дає Immer (пишеш мутативний синтаксис, отримуєш незмінні оновлення), auto-generated action creators і createAsyncThunk для async-потоків. Офіційна команда Redux рекомендує Toolkit для всіх нових проектів.
Race condition у thunks
store.dispatch(fetchUser(1)); // відправлено першим, відповідає пізніше
store.dispatch(fetchUser(2)); // відправлено другим, відповідає першим
// Без захисту повільніший запит перезаписує результат швидшогоРішення: зберігай requestId в стані коли запит стартує, потім у fulfilled перевіряй: якщо не збігається - пропускай оновлення.
Надмірна нормалізація малого стану
Вкладати { entities: { users: { 1: { name: 'Bob' } } } } для одного користувача - зайвий боілерплейт без жодної вигоди. Тримай плоску структуру, поки не маєш більше 10 пов'язаних елементів.
Де зустрічається в реальних проектах
- React / Next.js - глобальний стан для auth, кошика, сповіщень (Shopify Hydrogen використовує RTK Query)
- Electron-додатки - offline-first синхронізація стану (Discord desktop - відомий приклад)
- Node.js з WebSockets - спільний стан сесій між з'єднаннями
- Vanilla JS - стан гри для детермінованих реплеїв (поширено в Phaser-іграх)
- Альтернативи - Zustand для додатків до 500 рядків, Jotai для атомарного стану, TanStack Query коли більшість стану - серверні дані
Follow-up питання
Q: Опиши Redux-потік від dispatch до re-render у React.
A: Action потрапляє в dispatch, проходить через middleware (наприклад, thunk вирішує async-роботу), досягає кореневого reducer, який викликає дочірні через combineReducers. Кожен повертає новий фрагмент. Redux збирає новий об'єкт стану, повідомляє підписників. React's useSelector запускає селектор, порівнює результат за посиланням і перерендерює компонент, якщо він змінився.
Q: Чому reducers мають бути чистими функціями?
A: Чиста функція повертає однаковий результат для однакового вводу і не має побічних ефектів. Саме це робить time-travel debugging можливим: відтвори будь-яку послідовність actions - отримаєш той самий стан. Це також дозволяє порівняння за посиланням для продуктивності і робить unit-тести тривіальними.
Q: Що Redux Toolkit додає порівняно з vanilla Redux?
A: Immer (пишеш мутативний синтаксис, отримуєш незмінні оновлення), createSlice (автоматично генерує action creators і типи), configureStore (додає Redux DevTools і thunk middleware за замовчуванням), createAsyncThunk (обробляє стани pending/fulfilled/rejected) і RTK Query (fetching з кешуванням). Прибирає приблизно 70% шаблонного коду.
Q: Як реалізувати combineReducers самостійно?
A:
function combineReducers(reducers) {
return (state = {}, action) => {
return Object.keys(reducers).reduce((nextState, key) => {
nextState[key] = reducers[key](state[key], action);
return nextState;
}, {});
};
}Кожен ключ отримує свій фрагмент стану. Кореневий reducer викликає кожен дочірній і збирає результат.
Q: Як боротися з race conditions у async thunks?
A: createAsyncThunk з Redux Toolkit прикріплює requestId до кожного dispatched thunk. Зберігай останній requestId в стані на початку запиту. У fulfilled перевіряй: if (action.meta.requestId !== state.currentRequestId) return;. Це відкидає відповіді від застарілих запитів.
Приклади
Базовий Redux-потік
import { createStore } from 'redux';
const reducer = (state = { count: 0 }, action) => {
switch (action.type) {
case 'INCREMENT': return { count: state.count + 1 };
case 'DECREMENT': return { count: state.count - 1 };
default: return state;
}
};
const store = createStore(reducer);
store.subscribe(() => console.log(store.getState()));
store.dispatch({ type: 'INCREMENT' }); // { count: 1 }
store.dispatch({ type: 'INCREMENT' }); // { count: 2 }
store.dispatch({ type: 'DECREMENT' }); // { count: 1 }Store тримає один об'єкт. Кожен dispatch проходить через reducer. Стан ніколи не змінюється на місці.
Список задач з Redux Toolkit
import { configureStore, createSlice } from '@reduxjs/toolkit';
import { useSelector, useDispatch } from 'react-redux';
const todosSlice = createSlice({
name: 'todos',
initialState: [],
reducers: {
addTodo: (state, action) => { state.push(action.payload); }, // Immer обробляє незмінність
toggleTodo: (state, action) => { state[action.payload].completed = true; }
}
});
const store = configureStore({ reducer: { todos: todosSlice.reducer } });
function TodoList() {
const todos = useSelector(state => state.todos);
const dispatch = useDispatch();
return (
<ul>
{todos.map((todo, i) => (
<li key={i} onClick={() => dispatch(todosSlice.actions.toggleTodo(i))}>
{todo.text}
</li>
))}
<button onClick={() => dispatch(todosSlice.actions.addTodo({ text: 'Нове завдання', completed: false }))}>
Додати
</button>
</ul>
);
}createSlice автоматично генерує action creators. Immer перехоплює state.push(...) всередині reducer і застосовує зміну як незмінне оновлення.
Async thunk з обробкою race condition
import { createAsyncThunk, createSlice } from '@reduxjs/toolkit';
const fetchUser = createAsyncThunk('user/fetch', async (id) => {
const response = await fetch(`/api/user/${id}`);
return response.json();
});
const userSlice = createSlice({
name: 'user',
initialState: { data: null, loading: false, currentRequestId: null },
extraReducers: (builder) => {
builder
.addCase(fetchUser.pending, (state, action) => {
state.loading = true;
state.currentRequestId = action.meta.requestId; // Відстежуємо поточний запит
})
.addCase(fetchUser.fulfilled, (state, action) => {
if (action.meta.requestId !== state.currentRequestId) return; // Відкидаємо застарілі відповіді
state.data = action.payload;
state.loading = false;
});
}
});Без перевірки requestId повільний перший запит може перезаписати результат швидшого другого. Цей патерн запобігає цьому.
Коротка відповідь
Для співбесідиКоротка відповідь допоможе вам впевнено відповідати на цю тему під час співбесіди.