Skip to main content

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.

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

Ключова різниця - в тому, що тригерить перерендер:

jsx
// 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 APIRedux (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:

jsx
// Погано - новий об'єкт при кожному рендері <Context.Provider value={{ theme, user, cart }}> // Виправлення - мемоізувати значення const value = useMemo(() => ({ theme, user }), [theme, user]); <Context.Provider value={value}>

Новий об'єктний літерал створюється при кожному рендері, тому shallow equality завжди не виконується і всі споживачі перерендеряться, навіть якщо нічого важливого не змінилось.

Context для стану, що часто оновлюється:

jsx
// Погано - введення тексту зі швидкістю 60fps перерендерить все піддерево const [searchQuery, setSearchQuery] = useState(''); <SearchContext.Provider value={{ searchQuery, setSearchQuery }}>

Тримай стан, що часто оновлюється, локально. У Context передавай тільки фінальне значення.

Занадто широкий селектор у Redux:

jsx
// Погано - перерендер при будь-якій зміні стору const todos = useSelector(state => state.todos); // Виправлення - вибирай тільки потрібний шматок const todoList = useSelector(state => state.todos.list);

Пряма мутація стану в редюсері без RTK:

jsx
// Погано - ламає 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

jsx
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

jsx
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: посилання на об'єкти та мемоізація

jsx
// 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 працює так, як ти очікуєш.

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

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

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

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