Увійти Реєстрація
Блог Серії
Кар'єра
Вакансії Компанії
Навчання
Документація Співбесіди Тестування Відео
Екосистема
Пакети Ресурси Проєкти Інструменти Події
Інше
Про нас Реклама

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 чи невеликий стор. Багато застосунків після переходу виявляють, що глобальний стор їм майже не потрібен.

Докладніше в документації: TanStack Query: огляд

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.

Докладніше в документації: Redux Toolkit: початок роботи

Стан каталогу (фільтри, пошук, сортування, сторінка) у 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 зручний: сервер отримає масив.

Докладніше в документації: React Router: useSearchParams

Збереження стану між сесіями (тема, згорнута бічна панель, чернетка форми) - типова задача. Найпростіший варіант:

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 - вони вже враховують частину цих проблем.

Докладніше в документації: Window.localStorage