Redux проти context API
Redux vs Context API: Redux - це централізоване сховище стану, яке управляє даними через екшени та редюсери в окремому сторі; Context API - вбудований спосіб React передавати дані вниз по дереву компонентів без prop drilling.
Теорія
TL;DR
- Context API - як сімейний груповий чат: просто, всі бачать усе, легко налаштувати. Redux - як корпоративний Slack з каналами та логами: структурований, для команд.
- Головна різниця: Context перерендерить кожного споживача при зміні значення провайдера; Redux перерендерить тільки компоненти, підписані через
useSelectorна конкретний шматок стану. - До 5-10 компонентів із простим станом? Context. Складна async-логіка, 10+ компонентів, або командний дебагінг? Redux.
- Redux додає boilerplate, але дає time-travel дебагінг через Redux DevTools; Context не дає нічого з коробки.
- Redux Toolkit (RTK) суттєво скорочує boilerplate порівняно з класичним Redux.
Швидкий приклад
Ключова різниця - в тому, що тригерить перерендер:
// Context API - ВСІХ споживачів перерендерить при БУДЬ-ЯКІЙ зміні
const AppContext = React.createContext();
function App() {
const [theme, setTheme] = useState('light');
const [user, setUser] = useState(null);
return (
// Один спільний об'єкт - будь-яка зміна тригерить усіх споживачів
<AppContext.Provider value={{ theme, user }}>
<ThemeConsumer /> {/* Перерендер при зміні theme АБО user */}
<UserConsumer /> {/* Перерендер при зміні theme АБО user */}
</AppContext.Provider>
);
}
// Redux - перерендерить тільки підписаний компонент
function ThemeConsumer() {
const theme = useSelector(state => state.theme); // Тільки при зміні theme
return <div>{theme}</div>;
}Коли user змінюється в прикладі з Context, ThemeConsumer теж перерендериться, хоча user йому не потрібен. useSelector у Redux пропускає цей перерендер повністю.
Ключова різниця
Redux нав'язує однонаправлений потік даних: ти диспатчиш екшен, чистий редюсер обчислює наступний стан, і тільки компоненти, підписані на відповідний шматок стану, перерендеряться. Context просто передає значення вниз по дереву. У ньому немає поняття екшенів, редюсерів чи вибіркових підписок. Будь-яка зміна об'єкта провайдера тригерить перерендер у кожного споживача нижче. Це не баг, а дизайн, але він стає проблемою коли додаток росте.
Коли що використовувати
- Проста зміна теми або статус авторизації в 1-5 компонентах: Context API.
- Стан форми між сусідніми компонентами без async-логіки: Context API.
- Загальний стан із API-запитами, флагами завантаження та обробкою помилок: Redux з
createAsyncThunk. - 10+ компонентів зі станом, що часто оновлюється: Redux + Reselect.
- Командний проект із потребою в дебагінгу та код-рев'ю: Redux (лог екшенів коштує свого).
- Списки з великою кількістю елементів і частими оновленнями: Redux + Reselect.
Таблиця порівняння
| Функція | Context API | Redux (RTK) |
|---|---|---|
| Налаштування | createContext() + Provider, без залежностей | @reduxjs/toolkit + configureStore() |
| Boilerplate | Мінімальний | Середній (слайси, екшени) |
| Перерендери | Всі споживачі при зміні значення | Тільки відповідні useSelector виклики |
| DevTools | Немає | Time-travel, лог екшенів (Redux DevTools) |
| Async | Вручну (useEffect + локальний стан) | createAsyncThunk з коробки |
| Масштабованість | Малі та середні застосунки | Великі застосунки, команди |
| Коли використовувати | Теми, токен авторизації, локаль | Кошик, дашборд із API-синхронізацією |
Як це працює всередині
React Context використовує fiber-дерево: Provider записує значення у context._currentValue, а кожен споживач іде вгору по fiber-стеку до найближчого Provider. Будь-яка зміна об'єкта провайдера не проходить перевірку на поверхневу рівність (shallow equality) і тригерить scheduleUpdateOnFiber для всіх споживачів нижче.
Redux зберігає стан у звичайному JS-об'єкті (в RTK загорнутому у Immer для безпечних мутацій). Коли ти диспатчиш екшен, стор запускає редюсери, потім сповіщає підписані компоненти. useSelector порівнює попереднє і нове вибране значення на строгу рівність - якщо однакове, компонент не перерендериться. У React 18 Redux групує оновлення через unstable_batchedUpdates, тому кілька диспатчів в одному обробнику подій не викликають кілька рендер-циклів.
Типові помилки
Весь стан в одному об'єкті Context:
// Погано - новий об'єкт при кожному рендері
<Context.Provider value={{ theme, user, cart }}>
// Виправлення - мемоізувати значення
const value = useMemo(() => ({ theme, user }), [theme, user]);
<Context.Provider value={value}>Новий об'єктний літерал створюється при кожному рендері, тому shallow equality завжди не виконується і всі споживачі перерендеряться, навіть якщо нічого важливого не змінилось.
Context для стану, що часто оновлюється:
// Погано - введення тексту зі швидкістю 60fps перерендерить все піддерево
const [searchQuery, setSearchQuery] = useState('');
<SearchContext.Provider value={{ searchQuery, setSearchQuery }}>Тримай стан, що часто оновлюється, локально. У Context передавай тільки фінальне значення.
Занадто широкий селектор у Redux:
// Погано - перерендер при будь-якій зміні стору
const todos = useSelector(state => state.todos);
// Виправлення - вибирай тільки потрібний шматок
const todoList = useSelector(state => state.todos.list);Пряма мутація стану в редюсері без RTK:
// Погано - ламає Redux DevTools і передбачуваність
reducer: (state, action) => {
state.arr.push(action.payload); // Пряма мутація
return state;
}
// Виправлення з RTK - Immer робить це безпечним
reducers: {
addItem: (state, action) => {
state.arr.push(action.payload); // RTK/Immer перетворить на незмінне оновлення
}
}У класичному Redux без RTK завжди повертай новий об'єкт. З RTK Immer-draft дозволяє писати код у стилі мутації, який під капотом виробляє незмінний результат.
Де використовується на практиці
- React docs:
ThemeContextдля передачі теми вниз по дереву компонентів. - Next.js застосунки: auth context для сесії та даних користувача.
- Shopify Polaris: Redux для централізованого стану мерчанта в адмін-панелі.
- VS Code розширення: Redux для стану воркспейсу між панелями редактора.
- RTK Query у великих застосунках: кешування даних із автоматичною інвалідацією кешу.
Я бачив таке в реальних проектах: починали з Context для авторизації, потім додали для кошика, потім для сповіщень. Коли в дереві з'явилося 4 вкладені контексти, дебагінг перетворився на гадання. Після міграції на Redux лог екшенів сам по собі виправдав усі зусилля.
Питання на співбесіді
Q: Як Context викликає зайві перерендери і як це виправити?
A: Будь-яка зміна об'єкта провайдера тригерить перерендер у всіх споживачів, бо об'єктні літерали не проходять shallow equality при кожному рендері. Виправлення: useMemo для значення провайдера, розподіл на окремі контексти за частотою оновлення, або перехід на Zustand для цього шматка стану.
Q: Поясни потік Redux: екшен, thunk, редюсер, компонент.
A: Ти диспатчиш екшен (звичайний об'єкт з полем type). Якщо асинхронно, thunk middleware перехоплює його, виконує async-роботу, потім диспатчить звичайний екшен. Редюсер отримує екшен і повертає новий стан. Компоненти з useSelector перевіряють, чи змінився їхній шматок стану, і якщо так - перерендеряться.
Q: Чому Redux, а не Zustand або Jotai для великого застосунку?
A: Redux DevTools з time-travel дебагінгом, зріла екосистема middleware (RTK Query, redux-saga), і усталені патерни для командної роботи. Zustand легший і добре підходить для середніх застосунків, але не дає такої глибини DevTools і гарантій передбачуваності при масштабуванні.
Q: Порівняй продуктивність Context і Redux для 1000 елементів.
A: Redux з Reselect нормалізує вибірки приблизно до O(1) через мемоізовані селектори. Context тригерить O(n) обхід fiber-дерева при кожній зміні значення. Для великих списків із частими оновленнями різниця стає помітною під час профілювання.
Q: Як у React 18 concurrent mode батчінг Redux взаємодіє з startTransition?
A: Redux використовує unstable_batchedUpdates для групування диспатчів. У concurrent mode startTransition позначає оновлення як не-термінові, тому UI залишається чуйним під час важких обчислень у селекторах. Для async thunk-ів, що мають показувати UI-фідбек (спінер завантаження), використовуй useTransition поряд із диспатчем.
Приклади
Перемикач теми через Context API
const ThemeContext = React.createContext();
function ThemeProvider({ children }) {
const [theme, setTheme] = useState('light');
// Мемоізуємо щоб уникнути зайвих перерендерів
const value = useMemo(() => ({ theme, setTheme }), [theme]);
return (
<ThemeContext.Provider value={value}>
{children}
</ThemeContext.Provider>
);
}
function ThemeButton() {
const { theme, setTheme } = useContext(ThemeContext);
return (
<button onClick={() => setTheme(t => t === 'light' ? 'dark' : 'light')}>
Поточна тема: {theme}
</button>
);
}Тема змінюється рідко, споживачів мало, async-логіки немає. Це класичний випадок для Context.
Todos з API через Redux Toolkit
import { createSlice, createAsyncThunk } from '@reduxjs/toolkit';
export const fetchTodos = createAsyncThunk('todos/fetch', async () => {
const res = await fetch('/api/todos');
return res.json();
});
const todosSlice = createSlice({
name: 'todos',
initialState: { list: [], filter: 'all', loading: false },
reducers: {
setFilter: (state, action) => {
state.filter = action.payload; // Immer подбає про незмінність
}
},
extraReducers: (builder) => {
builder
.addCase(fetchTodos.pending, (state) => { state.loading = true; })
.addCase(fetchTodos.fulfilled, (state, action) => {
state.list = action.payload;
state.loading = false;
});
}
});
// Компонент підписується тільки на потрібне
function TodoList() {
const list = useSelector(state => state.todos.list); // Ігнорує зміни filter
const dispatch = useDispatch();
useEffect(() => { dispatch(fetchTodos()); }, [dispatch]);
return <ul>{list.map(todo => <li key={todo.id}>{todo.title}</li>)}</ul>;
}TodoList перерендериться тільки при зміні todos.list. Зміна фільтра не зачіпає його. З Context обидва компоненти ділили б один об'єкт значення і обидва перерендерялись би при кожному оновленні стану.
Edge case: посилання на об'єкти та мемоізація
// Context - новий об'єкт при кожному рендері ламає React.memo
function BadProvider({ children }) {
const [count, setCount] = useState(0);
return (
// Новий об'єкт кожен рендер - React.memo на Child не допоможе
<Context.Provider value={{ count, increment: () => setCount(c => c + 1) }}>
<Child />
</Context.Provider>
);
}
// Redux - Immer створює нове посилання тільки якщо дані реально змінились
const counterSlice = createSlice({
name: 'counter',
initialState: { count: 0 },
reducers: {
increment: state => { state.count += 1; } // Тільки count отримує нове посилання
}
});
// MemoizedChild пропускає перерендер при оновленні інших слайсів
const MemoizedChild = React.memo(function Child() {
const count = useSelector(state => state.counter.count);
return <div>{count}</div>;
});Immer створює нове посилання на об'єкт тільки коли дані справді змінились. У парі з вузьким селектором React.memo працює так, як ти очікуєш.
Коротка відповідь
Для співбесідиКоротка відповідь допоможе вам впевнено відповідати на цю тему під час співбесіди.