Підходи до управління станом у React
Управління станом у React (state management) - це система зберігання, оновлення та передачі даних між компонентами: від одного виклику useState до глобального стору або серверного кешу.
Теорія
Коротко
useState- особиста полиця: швидко, локально, нуль налаштувань- Context - спільний холодильник: доступний всій app, але кожен споживач ре-рендериться при зміні
- Zustand покриває 90% кейсів із глобальним станом з мінімальним кодом (+3KB)
- TanStack Query - для серверного стану: кешування, фоновий рефетч, мутації
- Правило вибору: менше 3 компонентів ділять дані?
useState. Складна асинхронність? TanStack Query. Глобальний UI? Zustand.
Швидкий приклад
// Локальний стан: компонент зберігає свій лічильник
function Counter() {
const [count, setCount] = useState(0);
return <button onClick={() => setCount(c => c + 1)}>{count}</button>;
}
// Спільний стан: тема доступна будь-де без prop drilling
const ThemeContext = createContext<"light" | "dark">("light");
function App() {
const [theme, setTheme] = useState<"light" | "dark">("light");
return (
<ThemeContext.Provider value={theme}>
<Child /> {/* читає тему через useContext, без пропсів */}
</ThemeContext.Provider>
);
}useState ре-рендерить лише свій компонент. Context ре-рендерить всіх споживачів. Ця різниця відчутна при масштабуванні.
Головний поділ
Стан у React ділиться на три категорії за областю видимості. Локальний стан (useState, useReducer) живе в одному компоненті й тригерить ре-рендер тільки там. Спільний стан (Context, Zustand, Redux) поширює зміни по дереву для таких даних як авторизація або кошик. Серверний стан (TanStack Query, SWR) - це окрема категорія: вона відповідає за асинхронні запити, кешування та фонову синхронізацію, що не має стосунку до UI-стану.
Змішування цих категорій - найпоширеніша проблема в кодових базах. Команда кладе відповіді API у Redux, а потім дивується, чому інвалідація кешу така болюча.
Коли що використовувати
- Один компонент (перемикач, інпут, модальне вікно) →
useState, без варіантів - Складна локальна логіка (багатокроковий флоу, undo-історія) →
useReducerз типізованим union type дій - 2-5 компонентів, що діляться рідко змінюваними даними (тема, локаль, токен авторизації) → Context API
- Глобальний стан, що часто змінюється (кошик, налаштування, сповіщення) → Zustand для мінімального API або Redux Toolkit для великих команд із строгими конвенціями
- Дані з API (список користувачів, результати пошуку, пагінація) → TanStack Query або SWR
- Складні форми з валідацією → React Hook Form, uncontrolled-підхід без ре-рендеру на кожен символ
- Фільтри, пагінація, сортування → URL params через
URLSearchParams, зберігаються після перезавантаження і шаряться посиланням
Таблиця порівняння
| Підхід | Область | Шаблонний код | Async | Вага | Для чого |
|---|---|---|---|---|---|
useState | Локальний | Відсутній | Вручну | 0 | UI перемикачі, інпути |
useReducer | Локальний/shared | Мінімальний | Вручну | 0 | Складна локальна логіка |
| Context API | App-wide | Мінімальний | Вручну | 0 | Тема, auth, локаль |
| Zustand | Глобальний | Мінімальний | Через плагіни | +3KB | Більшість додатків |
| Redux Toolkit | Глобальний | Середній | RTK Query | +12KB | Великі команди, legacy |
| TanStack Query | Серверний | Мінімальний | Вбудований кеш | +14KB | API-heavy додатки |
| React Hook Form | Форми | Мінімальний | N/A | +9KB | Валідація |
Як це працює зсередини
React ставить оновлення стану в чергу у fiber-вузлах. Кожен виклик useState підключається до диспетчера й прив'язує чергу оновлень до індексу хука. У React 18 оновлення всередині обробників подій автоматично пакетуються, тому кілька викликів setState дають один ре-рендер замість кількох.
Context Provider створює обхідник дерева. Коли значення контексту змінюється, React обходить піддерево й позначає компоненти-споживачі для ре-рендеру. Вбудованого механізму селекторів немає: якщо об'єкт-значення змінює посилання, всі споживачі ре-рендеряться. Саме тому великий об'єкт у Context без мемоізації - це проблема продуктивності.
TanStack Query використовує глобальний Map з ключами запитів. Запити проходять через AbortController, тому навігація зі сторінки скасовує незавершені запити. Visibility API тригерить фоновий рефетч, коли користувач повертається у вкладку. Все це працює повністю поза моделлю стану React.
Типові помилки
Піднімати весь стан нагору й передавати пропсами вниз.
// Неправильно: кожен дочірній компонент ре-рендериться при будь-якій зміні кошика
function App() {
const [cart, setCart] = useState([]);
return <Header cart={cart} />; // Header ре-рендериться навіть якщо показує тільки кількість
}
// Правильно: селектор, підписуємось тільки на те що потрібно
const cartCount = useCartStore(state => state.items.length);
// Ре-рендер тільки при зміні кількості, не при будь-якій мутації кошикаМутувати стан напряму.
// Неправильно: React не бачить змін, ре-рендеру немає
const addItem = () => {
cart.push(newItem);
setCart(cart); // те саме посилання, React ігнорує
};
// Правильно: нове посилання на масив
setCart([...cart, newItem]);Не вказувати залежності у ключах TanStack Query.
// Неправильно: кеш не оновлюється при зміні userId
useQuery({ queryKey: ["posts"], queryFn: () => fetchPosts(userId) });
// Правильно: userId входить у ключ
useQuery({ queryKey: ["posts", userId], queryFn: () => fetchPosts(userId) });Класти все в один Context. Коли один Context містить забагато, будь-яка зміна ре-рендерить все піддерево. Розбивай на менші, фокусовані Contexts або переходь на Zustand.
Де зустрічається в реальних проектах
- Next.js додатки на Vercel використовують TanStack Query для fetching'у з паттерном stale-while-revalidate
- Shopify admin використовує Redux Toolkit зі структурованими слайсами для складного стану e-commerce
- Discord web-клієнт використовує Zustand для легкого глобального UI-стану
- Stripe checkout використовує React Hook Form для валідації без ре-рендеру на кожен символ
- Більшість команд, що спочатку беруть Redux, після рефакторингу переходять на Zustand
Follow-up питання
Q: Коли useState спричиняє проблеми з продуктивністю?
A: Коли той самий стан передається через глибоке дерево пропсами. Кожен setState тригерить ре-рендер у кожному споживачі. Вирішується переходом на Context з мемоізацією або на Zustand-селектори.
Q: В чому різниця між Zustand і Redux Toolkit?
A: Redux дає більше структури: actions, reducers, slices, строгі конвенції, що допомагають великим командам залишатись послідовними. Zustand досягає того самого з набагато меншою кількістю коду. Для команди з 2-5 розробників Zustand - майже завжди правильний вибір.
Q: Як TanStack Query запобігає request waterfalls?
A: Паралельні запити через suspense-межі й prefetchQuery на рівні роутера, тому дані завантажуються до того, як компоненти монтуються.
Q: Що не так із useReducer плюс Context для великих додатків?
A: Без селекторів будь-яка зміна значення контексту ре-рендерить усе дерево. Вбудованого способу підписатися на частину контексту немає. Zustand вирішує це через useStore(selector).
Q: Як transitions у React 18 впливають на оновлення стану?
A: useTransition позначає оновлення як низькопріоритетні. Важкий фільтр, позначений як transition, залишає інпут відзивчивим, поки результати оновлюються у фоні.
Q: Опиши optimistic updates у TanStack Query.
A: У useMutation встановлюєш onMutate для негайного оновлення кешу і зберігаєш попереднє значення, потім викликаєш onError для відкату, якщо сервер повертає помилку.
Приклади
Локальний стан: контрольований інпут
function SearchInput() {
const [query, setQuery] = useState("");
return (
<input
value={query}
onChange={e => setQuery(e.target.value)}
placeholder="Пошук..."
/>
);
}
// Кожен символ оновлює query, тільки цей компонент ре-рендеритьсяКонтрольовані інпути - найпоширеніший випадок для useState. Компонент тримає значення, React контролює цикл рендеру.
Глобальний стан: Zustand-стор для кошика
import { create } from "zustand";
interface CartItem { id: number; qty: number; price: number; }
interface CartStore {
items: CartItem[];
addItem: (item: CartItem) => void;
removeItem: (id: number) => void;
total: () => number;
}
export const useCartStore = create<CartStore>((set, get) => ({
items: [],
addItem: (item) => set(state => ({ items: [...state.items, item] })),
removeItem: (id) => set(state => ({
items: state.items.filter(i => i.id !== id),
})),
total: () => get().items.reduce((sum, i) => sum + i.price * i.qty, 0),
}));
// Ре-рендер тільки при зміні кількості елементів, не при зміні цін
function CartBadge() {
const count = useCartStore(state => state.items.length);
return <span className="badge">{count}</span>;
}Селектор state => state.items.length - ось що робить Zustand продуктивним тут. CartBadge ре-рендериться тільки при зміні кількості, а не при оновленні цін чи кількості товарів.
Серверний стан: TanStack Query з мутацією
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
function UserList() {
const queryClient = useQueryClient();
const { data: users, isLoading } = useQuery({
queryKey: ["users"],
queryFn: () => fetch("/api/users").then(r => r.json()),
});
const deleteUser = useMutation({
mutationFn: (id: number) =>
fetch(`/api/users/${id}`, { method: "DELETE" }),
onSuccess: () => {
// Позначає кеш як застарілий, тригерить автоматичний рефетч
queryClient.invalidateQueries({ queryKey: ["users"] });
},
});
if (isLoading) return <p>Завантаження...</p>;
return (
<ul>
{users.map(user => (
<li key={user.id}>
{user.name}
<button onClick={() => deleteUser.mutate(user.id)}>Видалити</button>
</li>
))}
</ul>
);
}invalidateQueries після мутації позначає кеш як застарілий і тригерить фоновий рефетч. Жодного useEffect вручну, жодного жонглювання станом.
Коротка відповідь
Для співбесідиКоротка відповідь допоможе вам впевнено відповідати на цю тему під час співбесіди.