Skip to main content

Підходи до управління станом у React

Управління станом у React (state management) - це система зберігання, оновлення та передачі даних між компонентами: від одного виклику useState до глобального стору або серверного кешу.

Теорія

Коротко

  • useState - особиста полиця: швидко, локально, нуль налаштувань
  • Context - спільний холодильник: доступний всій app, але кожен споживач ре-рендериться при зміні
  • Zustand покриває 90% кейсів із глобальним станом з мінімальним кодом (+3KB)
  • TanStack Query - для серверного стану: кешування, фоновий рефетч, мутації
  • Правило вибору: менше 3 компонентів ділять дані? useState. Складна асинхронність? TanStack Query. Глобальний UI? Zustand.

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

tsx
// Локальний стан: компонент зберігає свій лічильник 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ЛокальнийВідсутнійВручну0UI перемикачі, інпути
useReducerЛокальний/sharedМінімальнийВручну0Складна локальна логіка
Context APIApp-wideМінімальнийВручну0Тема, auth, локаль
ZustandГлобальнийМінімальнийЧерез плагіни+3KBБільшість додатків
Redux ToolkitГлобальнийСереднійRTK Query+12KBВеликі команди, legacy
TanStack QueryСервернийМінімальнийВбудований кеш+14KBAPI-heavy додатки
React Hook FormФормиМінімальнийN/A+9KBВалідація

Як це працює зсередини

React ставить оновлення стану в чергу у fiber-вузлах. Кожен виклик useState підключається до диспетчера й прив'язує чергу оновлень до індексу хука. У React 18 оновлення всередині обробників подій автоматично пакетуються, тому кілька викликів setState дають один ре-рендер замість кількох.

Context Provider створює обхідник дерева. Коли значення контексту змінюється, React обходить піддерево й позначає компоненти-споживачі для ре-рендеру. Вбудованого механізму селекторів немає: якщо об'єкт-значення змінює посилання, всі споживачі ре-рендеряться. Саме тому великий об'єкт у Context без мемоізації - це проблема продуктивності.

TanStack Query використовує глобальний Map з ключами запитів. Запити проходять через AbortController, тому навігація зі сторінки скасовує незавершені запити. Visibility API тригерить фоновий рефетч, коли користувач повертається у вкладку. Все це працює повністю поза моделлю стану React.

Типові помилки

Піднімати весь стан нагору й передавати пропсами вниз.

tsx
// Неправильно: кожен дочірній компонент ре-рендериться при будь-якій зміні кошика function App() { const [cart, setCart] = useState([]); return <Header cart={cart} />; // Header ре-рендериться навіть якщо показує тільки кількість } // Правильно: селектор, підписуємось тільки на те що потрібно const cartCount = useCartStore(state => state.items.length); // Ре-рендер тільки при зміні кількості, не при будь-якій мутації кошика

Мутувати стан напряму.

tsx
// Неправильно: React не бачить змін, ре-рендеру немає const addItem = () => { cart.push(newItem); setCart(cart); // те саме посилання, React ігнорує }; // Правильно: нове посилання на масив setCart([...cart, newItem]);

Не вказувати залежності у ключах TanStack Query.

tsx
// Неправильно: кеш не оновлюється при зміні 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 для відкату, якщо сервер повертає помилку.

Приклади

Локальний стан: контрольований інпут

tsx
function SearchInput() { const [query, setQuery] = useState(""); return ( <input value={query} onChange={e => setQuery(e.target.value)} placeholder="Пошук..." /> ); } // Кожен символ оновлює query, тільки цей компонент ре-рендериться

Контрольовані інпути - найпоширеніший випадок для useState. Компонент тримає значення, React контролює цикл рендеру.

Глобальний стан: Zustand-стор для кошика

tsx
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 з мутацією

tsx
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 вручну, жодного жонглювання станом.

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

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

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

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