Middle: питання на співбесіді з теми «Стан і дані»
Питання з реальних співбесід з відповідями: Laravel і PHP, бази даних, JavaScript і фронтенд, Git, Docker, API, безпека й архітектура. Тими самими темами, що й тести.
4 питання
Клієнтський стан належить інтерфейсу: відкрите модальне вікно, текст у полі, обрана вкладка. Він живе в браузері, і застосунок - його єдиний власник.
Серверний стан - копія даних, що живуть на сервері: список замовлень, профіль, вакансії. Його особливості:
- може змінитися без участі вашого застосунку (інший користувач, фонова задача);
- потребує завантаження, кешування, повторних запитів, обробки помилок;
- може бути застарілим у будь-який момент.
Зберігати його в useState чи глобальному сторі - означає вручну писати кеш, стани завантаження, інвалідацію й дедуплікацію запитів.
TanStack Query бере це на себе:
import { useQuery } from '@tanstack/react-query';
function Orders({ status }) {
const { data, isPending, error } = useQuery({
queryKey: ['orders', { status }],
queryFn: () => fetch(`/api/orders?status=${status}`).then((r) => r.json()),
staleTime: 30_000,
});
if (isPending) return <Spinner />;
if (error) return <ErrorMessage error={error} />;
return <OrderList orders={data} />;
}
Що дає:
- кеш за ключем (
queryKey): повернення на сторінку показує дані миттєво, а оновлення йде у фоні; - дедуплікація: десять компонентів з тим самим ключем - один запит;
- stale-while-revalidate: застарілі дані показуються, поки завантажуються свіжі;
- повторні запити при поверненні на вкладку, відновленні мережі, з інтервалом;
- повтори при помилках з затримкою;
- мутації (
useMutation) з інвалідацією кешу й оптимістичними оновленнями; - скасування застарілих запитів і відсутність гонитви.
Ключові налаштування:
staleTime- скільки даних вважаються свіжими (за замовчуванням 0 - застарілі одразу, тож повторний запит при кожному монтуванні). Для більшості даних варто задати секунди чи хвилини;gcTime- скільки невикористовувані дані лишаються в кеші;- ключ має містити всі параметри запиту - інакше різні запити ділитимуть один кеш.
Розподіл відповідальності: серверний стан - TanStack Query (чи SWR, RTK Query, завантажувачі фреймворку), клієнтський - useState, Context чи невеликий стор. Багато застосунків після переходу виявляють, що глобальний стор їм майже не потрібен.
Context - механізм передачі значення вниз по дереву, а не повноцінне керування станом. Його головне обмеження: зміна значення перерендерює всіх споживачів, навіть якщо їм потрібна лише частина даних.
Зовнішній стор тримає стан поза деревом React, а компоненти підписуються на окремі частини через селектори:
// Zustand
const useCartStore = create((set) => ({
items: [],
add: (item) => set((state) => ({ items: [...state.items, item] })),
}));
function CartBadge() {
const count = useCartStore((state) => state.items.length); // лише кількість
return <span>{count}</span>;
}
CartBadge перерендериться лише коли зміниться кількість, а не будь-що в сторі.
// Redux Toolkit
const cartSlice = createSlice({
name: 'cart',
initialState: { items: [] },
reducers: {
added(state, action) {
state.items.push(action.payload); // Immer усередині - виглядає як мутація, але незмінно
},
},
});
Коли зовнішній стор виправданий:
- часті оновлення спільного стану (редактор, канвас, дашборд у реальному часі) - селектори відсікають зайві рендери;
- багато компонентів у різних частинах дерева читають і змінюють той самий стан;
- складна логіка змін, яку зручно тримати поза компонентами й тестувати окремо;
- доступ поза React: з обробників WebSocket, сервісів, роутера;
- інструменти: Redux DevTools (історія дій, «подорож у часі»), збереження стану, middleware.
Коли не потрібен:
- дані з сервера - для них краще TanStack Query чи RTK Query, а не ручне зберігання в сторі;
- стан однієї сторінки чи компонента -
useState/useReducer; - рідко змінювані значення (тема, мова, поточний користувач) - Context цілком достатній.
Порівняння бібліотек:
- Redux Toolkit - структурований, з DevTools і RTK Query, більше коду, але передбачуваний для великих команд;
- Zustand - мінімальний API, хук-стор без провайдера;
- Jotai - атомарний підхід: стан з дрібних незалежних атомів.
Усі вони побудовані на useSyncExternalStore - безпечні для конкурентного рендерингу.
Пастка селекторів: селектор, що повертає новий об'єкт ((s) => ({ a: s.a, b: s.b })), щоразу дає «інше» значення і перерендерює компонент. Потрібні окремі селектори чи порівняння shallow.
Стан каталогу (фільтри, пошук, сортування, сторінка) у useState зникає при оновленні сторінки, не працює з кнопкою «Назад» і не передається посиланням. URL - природне місце для такого стану.
Що дає стан в URL:
- посилання відтворює екран: «ось вакансії PHP у Києві, віддалено» - одне посилання;
- «Назад» і «Вперед» повертають попередні фільтри;
- оновлення сторінки нічого не губить;
- серверний рендер і SEO: сервер бачить параметри й рендерить правильний вміст;
- аналітика бачить, як саме користуються фільтрами.
З React Router:
import { useSearchParams } from 'react-router';
function Vacancies() {
const [searchParams, setSearchParams] = useSearchParams();
const city = searchParams.get('city') ?? 'all';
const page = Number(searchParams.get('page') ?? 1);
function changeCity(nextCity) {
setSearchParams((params) => {
params.set('city', nextCity);
params.delete('page'); // новий фільтр - перша сторінка
return params;
});
}
// ...
}
Значення читаються з URL під час рендеру - окремий useState для них не потрібен. URL і є джерелом правди.
Без роутера - URLSearchParams + history.replaceState/pushState і підписка на popstate (через useSyncExternalStore).
Що варто врахувати:
pushчиreplace: зміна фільтра - новий запис історії (користувач повертається «Назад» до попередніх фільтрів); введення в поле пошуку -replace, щоб не засмічувати історію кожною літерою;- debounce для полів введення: не змінювати URL на кожне натискання;
- валідація: параметри з URL - дані від користувача. Невідоме значення сортування чи від'ємна сторінка мають оброблятися без падіння;
- типи: усе в URL - рядки; числа й булеві значення треба перетворювати (бібліотеки на кшталт
nuqsроблять це з типами); - короткі й стабільні назви параметрів - вони стають частиною публічних посилань.
Що НЕ варто тримати в URL: тимчасовий стан інтерфейсу (відкрите меню, наведення), персональні дані, великі обсяги даних.
Для Laravel-бекенду формат на кшталт tags[]=php&tags[]=vue зручний: сервер отримає масив.
Збереження стану між сесіями (тема, згорнута бічна панель, чернетка форми) - типова задача. Найпростіший варіант:
function useLocalStorageState(key, initialValue) {
const [value, setValue] = useState(() => {
try {
const stored = localStorage.getItem(key);
return stored !== null ? JSON.parse(stored) : initialValue;
} catch {
return initialValue;
}
});
useEffect(() => {
try {
localStorage.setItem(key, JSON.stringify(value));
} catch {
// сховище заповнене чи недоступне - стан працює і без збереження
}
}, [key, value]);
return [value, setValue];
}
Лінива ініціалізація (функція в useState) - читання з localStorage лише один раз, а не на кожному рендері.
Пастки:
1. Серверний рендер. На сервері localStorage не існує - код вище впаде з ReferenceError. А якщо перевіряти typeof window, сервер відрендерить початкове значення, клієнт - збережене, і React повідомить про розбіжність гідратації.
Правильно - на першому рендері використовувати те саме значення, що й сервер, і читати сховище після монтування; або useSyncExternalStore з getServerSnapshot, що повертає значення за замовчуванням. Для теми, щоб не було «спалаху», - невеликий скрипт у <head>, що ставить клас до запуску React, або cookie, яку бачить і сервер.
2. Синхронізація між вкладками. Зміни в одній вкладці не видно в іншій без підписки на подію storage. useSyncExternalStore з підпискою на неї вирішує це.
3. Зміна формату даних. Нова версія застосунку очікує інший формат, а в браузерах лежать старі дані. Додавайте версію в ключ (settings:v2) чи валідацію при читанні (Zod) - некоректні дані замінюються значенням за замовчуванням.
4. Винятки. localStorage кидає помилки в приватному режимі, при заповненому сховищі (~5 МБ), при заборонених cookie - доступ обгортається в try/catch.
5. Безпека. Будь-який скрипт на сторінці читає localStorage - туди не кладуть токени автентифікації й персональні дані.
6. Продуктивність. setItem синхронний; запис великого об'єкта на кожне натискання клавіші - помітні затримки. Для чернеток - debounce.
Готові рішення: useLocalStorage з бібліотек хуків, middleware persist у Zustand, redux-persist - вони вже враховують частину цих проблем.