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

Питання на співбесіді: Стан і дані

Питання з реальних співбесід з відповідями: Laravel і PHP, бази даних, JavaScript і фронтенд, Git, Docker, API, безпека й архітектура. Тими самими темами, що й тести.

12 питань

Коли два компоненти мають показувати чи змінювати одні й ті самі дані, стан не може жити в кожному з них окремо - копії розійдуться. Його переносять у найближчого спільного предка й передають вниз через props.

Було - кожна панель сама вирішує, чи відкрита:

function Panel({ title, children }) {
  const [isOpen, setIsOpen] = useState(false);
  // ...
}

Вимога «відкрита лише одна панель одночасно» так не реалізується: панелі не знають одна про одну.

Стало - стан у батька, панелі керовані:

function Accordion() {
  const [openId, setOpenId] = useState('about');

  return (
    <>
      <Panel title="Про нас" isOpen={openId === 'about'} onOpen={() => setOpenId('about')}>...</Panel>
      <Panel title="Доставка" isOpen={openId === 'delivery'} onOpen={() => setOpenId('delivery')}>...</Panel>
    </>
  );
}

function Panel({ title, isOpen, onOpen, children }) {
  return (
    <section>
      <button onClick={onOpen}>{title}</button>
      {isOpen && children}
    </section>
  );
}

Panel став керованим (controlled): ним керує батько через props. Компонент зі своїм внутрішнім станом - некерований (uncontrolled).

Принцип «одного джерела правди»: для кожного шматка стану є один компонент-власник. Інші отримують значення через props і повідомляють про зміни через колбеки.

Куди піднімати:

  • до найближчого спільного предка всіх, хто використовує дані, - не вище. Стан занадто високо змушує перерендерюватися велику частину дерева й ускладнює компоненти;
  • якщо спільний предок дуже далеко і дані треба передавати через багато рівнів («prop drilling»), - спершу композиція (передати готовий JSX через children), потім Context.

Зворотний процес теж корисний: якщо стан використовується лише в одному компоненті, його варто опустити туди (state colocation) - менше зайвих рендерів і простіші батьки.

Типова помилка - дублювати стан: копіювати props у локальний стан дитини (useState(props.value)). Копія не оновиться, коли батько змінить значення. Або компонент керований (значення з props), або некерований (власний стан з початковим значенням), - змішування дає розсинхронізацію.

Докладніше в документації: Спільний стан між компонентами

Надлишковий стан - значення, яке можна обчислити з props чи іншого стану. Його зберігання - джерело помилок, бо дві копії правди рано чи пізно розходяться.

Погано:

const [firstName, setFirstName] = useState('');
const [lastName, setLastName] = useState('');
const [fullName, setFullName] = useState('');   // надлишкове

function handleFirstNameChange(e) {
  setFirstName(e.target.value);
  setFullName(e.target.value + ' ' + lastName);   // треба не забути оновити тут
}

Добре - обчислювати під час рендеру:

const fullName = `${firstName} ${lastName}`;

Типові випадки надлишкового стану:

  • відфільтрований чи відсортований список поруч з оригіналом:
const visibleTodos = todos.filter((t) => (showDone ? true : !t.done));
  • кількості й суми: items.length, items.reduce(...);
  • обраний елемент як копія об'єкта - краще зберігати selectedId і знаходити об'єкт: items.find((i) => i.id === selectedId). Інакше зміна елемента в списку не потрапить у «обраний»;
  • прапорці, що випливають з даних: isEmpty, hasErrors, canSubmit.

Антипатерн - синхронізація ефектом:

const [visibleTodos, setVisibleTodos] = useState([]);
useEffect(() => {
  setVisibleTodos(todos.filter(...));
}, [todos]);

Зайвий рендер зі старими даними, потім ще один - з новими. Плюс місце для помилки, якщо забути залежність.

А якщо обчислення дороге? Спершу виміряти. Якщо справді повільно - useMemo (або React Compiler зробить це автоматично):

const visibleTodos = useMemo(() => filterTodos(todos, query), [todos, query]);

Коли копія з props доречна: коли це початкове значення, яке далі живе своїм життям, - і тоді props варто назвати відповідно (initialColor, defaultValue), щоб було зрозуміло, що подальші зміни props не враховуються.

Перевірка структури стану: для кожного значення в useState запитати: «чи можна його обчислити з інших?». Якщо так - це не стан.

Докладніше в документації: Вибір структури стану

Найпростіше завантаження в ефекті:

function UserProfile({ userId }) {
  const [user, setUser] = useState(null);

  useEffect(() => {
    let ignore = false;

    fetch(`/api/users/${userId}`)
      .then((response) => response.json())
      .then((data) => {
        if (!ignore) setUser(data);
      });

    return () => {
      ignore = true;
    };
  }, [userId]);

  if (!user) return <Spinner />;
  return <h1>{user.name}</h1>;
}

Навіщо змінна ignore. Користувач швидко перемикає профілі: userId 1, потім 2. Летять два запити. Якщо відповідь для 1 прийде пізніше за відповідь для 2, без перевірки вона перезапише стан - на екрані профіль 2-го користувача з даними 1-го. Функція очищення попереднього ефекту ставить ignore = true, і застаріла відповідь ігнорується.

Ще краще - скасувати запит:

useEffect(() => {
  const controller = new AbortController();
  fetch(`/api/users/${userId}`, { signal: controller.signal })
    .then((r) => r.json())
    .then(setUser)
    .catch((error) => {
      if (error.name !== 'AbortError') setError(error);
    });
  return () => controller.abort();
}, [userId]);

StrictMode у розробці запускає ефект двічі - з очищенням між запусками. Перший запит скасовується. Це нормально й саме перевіряє, що очищення написане.

Чого бракує цьому підходу (і чому документація React радить не писати таке вручну у великих застосунках):

  • немає кешу: повернення на сторінку - новий запит і знову спінер;
  • «водоспад» запитів: дочірні компоненти починають завантаження лише після рендеру батька;
  • стани помилки й завантаження - у кожному компоненті вручну;
  • немає попереднього завантаження і повторного запиту при поверненні на вкладку;
  • серверний рендер: ефекти на сервері не виконуються.

Альтернативи:

  • фреймворк (Next.js, React Router з завантажувачами) - дані завантажуються до рендеру маршруту;
  • TanStack Query чи SWR - кеш, повтори, скасування, синхронізація;
  • use() з Suspense і промісом зі стабільного джерела.

useEffect для даних лишається прийнятним для невеликих застосунків і простих випадків - але з очищенням.

Докладніше в документації: Синхронізація з ефектами: завантаження даних

Для стану, яким користуються компоненти на різних рівнях дерева (кошик, список завдань, налаштування), React пропонує поєднання useReducer + Context без сторонніх бібліотек.

const TasksContext = createContext(null);
const TasksDispatchContext = createContext(null);

export function TasksProvider({ children }) {
  const [tasks, dispatch] = useReducer(tasksReducer, []);

  return (
    <TasksContext value={tasks}>
      <TasksDispatchContext value={dispatch}>
        {children}
      </TasksDispatchContext>
    </TasksContext>
  );
}

export function useTasks() {
  const tasks = useContext(TasksContext);
  if (tasks === null) throw new Error('useTasks має бути всередині TasksProvider');
  return tasks;
}

export function useTasksDispatch() {
  return useContext(TasksDispatchContext);
}

У React 19 сам контекст можна рендерити як провайдер (<TasksContext value={...}>); старий запис <TasksContext.Provider> теж працює.

Використання в будь-якому компоненті під провайдером:

function AddTask() {
  const dispatch = useTasksDispatch();
  return <button onClick={() => dispatch({ type: 'added', text: 'Нове завдання' })}>Додати</button>;
}

function TaskList() {
  const tasks = useTasks();
  return tasks.map((task) => <Task key={task.id} task={task} />);
}

Чому два контексти, а не один:

  • dispatch стабільний - не змінюється між рендерами;
  • компоненти, яким потрібна лише відправка дій (кнопки, форми), підписуються лише на TasksDispatchContext і не перерендерюються, коли змінюється список завдань.

Переваги підходу:

  • логіка змін - в одному редукторі, її легко тестувати;
  • компоненти не передають колбеки через десятки рівнів;
  • власні хуки (useTasks) приховують деталі й перевіряють наявність провайдера.

Обмеження, через які переходять на сховища:

  • будь-яка зміна значення контексту перерендерює всіх його споживачів - навіть тих, кому потрібна лише частина даних. Немає селекторів «підпишись лише на це поле»;
  • провайдери, що вкладаються один в один для кожної частини стану, ускладнюють структуру;
  • немає інструментів налагодження «з коробки», проміжних обробників (middleware), збереження стану.

Для частих оновлень і великого спільного стану - Zustand, Redux Toolkit чи подібні з підписками на частину стану.

Докладніше в документації: Масштабування з reducer і context

Клієнтський стан належить інтерфейсу: відкрите модальне вікно, текст у полі, обрана вкладка. Він живе в браузері, і застосунок - його єдиний власник.

Серверний стан - копія даних, що живуть на сервері: список замовлень, профіль, вакансії. Його особливості:

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

Зберігати його в 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

Найпідступніші помилки стану - неможливі комбінації, які структура даних дозволяє, а логіка - ні.

1. Суперечливі прапорці → одне поле статусу:

// погано: що означає isLoading && isError? а isSuccess && isError?
const [isLoading, setIsLoading] = useState(false);
const [isError, setIsError] = useState(false);
const [isSuccess, setIsSuccess] = useState(false);

// добре: рівно один стан у кожен момент
const [status, setStatus] = useState('idle');   // 'idle' | 'loading' | 'error' | 'success'

Ще краще - дискримінований тип, де дані існують лише в «правильних» станах:

type State =
  | { status: 'idle' }
  | { status: 'loading' }
  | { status: 'error'; error: string }
  | { status: 'success'; data: Order[] };

TypeScript не дасть прочитати data в стані error.

2. Дублювання → ідентифікатори:

// погано: копія об'єкта - зміна в списку не потрапить у selectedItem
const [selectedItem, setSelectedItem] = useState(items[0]);

// добре
const [selectedId, setSelectedId] = useState(items[0].id);
const selectedItem = items.find((item) => item.id === selectedId);

3. Глибока вкладеність → нормалізація. Дерево (категорії з підкатегоріями, коментарі з відповідями) зручно відображати, але незручно оновлювати: зміна глибокого вузла вимагає копіювати весь шлях. Нормалізована форма - як таблиці в базі:

{
  commentsById: {
    1: { id: 1, text: '...', childIds: [2, 3] },
    2: { id: 2, text: '...', childIds: [] },
  },
  rootIds: [1],
}

Оновлення одного коментаря - одна заміна за id. Redux Toolkit має для цього createEntityAdapter.

4. Групування пов'язаного стану: значення, що завжди змінюються разом (координати x і y, поля однієї форми), - в одному об'єкті чи редукторі, а не в окремих useState, які легко оновити неузгоджено.

5. Надлишковий стан → обчислення під час рендеру.

Перевірка структури: для кожної комбінації значень у стані запитати - чи вона можлива в реальності? Якщо ні, структуру варто змінити так, щоб її неможливо було виразити. «Зробити неможливі стани невиразними» - головна ідея.

Для складних сценаріїв (багатокрокові форми, процеси оплати, завантаження з повторами) - явний скінченний автомат: редуктор з переходами або бібліотека XState.

Докладніше в документації: Вибір структури стану

Оптимістичне оновлення - показати результат дії одразу, не чекаючи відповіді сервера, і відкотити, якщо сервер повернув помилку. Інтерфейс відчувається миттєвим: «лайк», позначка завдання, перейменування.

Варіант через кеш запиту:

const queryClient = useQueryClient();

const toggleTodo = useMutation({
  mutationFn: (todo) =>
    fetch(`/api/todos/${todo.id}`, { method: 'PATCH', body: JSON.stringify({ done: !todo.done }) }),

  onMutate: async (todo) => {
    await queryClient.cancelQueries({ queryKey: ['todos'] });          // 1
    const previous = queryClient.getQueryData(['todos']);               // 2
    queryClient.setQueryData(['todos'], (old) =>                        // 3
      old.map((t) => (t.id === todo.id ? { ...t, done: !t.done } : t)),
    );
    return { previous };
  },

  onError: (error, todo, context) => {
    queryClient.setQueryData(['todos'], context.previous);              // 4
    toast.error('Не вдалося зберегти');
  },

  onSettled: () => queryClient.invalidateQueries({ queryKey: ['todos'] }), // 5
});

Кроки й навіщо кожен:

  1. скасувати поточні запити списку - інакше відповідь, що вже летить, перезапише оптимістичні дані старими;
  2. зберегти знімок для відкату;
  3. оновити кеш - інтерфейс змінюється миттєво;
  4. при помилці - відкотити до знімка і повідомити користувача;
  5. після завершення - перезапитати дані з сервера, щоб кеш точно відповідав реальності.

Простіший варіант - через змінні мутації: не чіпати кеш, а в компоненті показувати mutation.variables, поки мутація в стані isPending. Менше коду, відкат автоматичний (просто зникає тимчасове відображення), але зміна видна лише там, де рендериться мутація.

React 19 useOptimistic - схожа ідея для дій і форм без бібліотеки: тимчасовий стан, що діє до завершення асинхронної дії.

Коли оптимізм доречний: дія майже завжди успішна, легко відкочується і не має незворотних наслідків.

Коли ні: оплати, відправка листів, видалення без можливості відновлення, операції з високим шансом відмови через валідацію - там краще чесний стан «Зберігаємо...».

Пастки:

  • кілька швидких мутацій однієї сутності: відкат однієї може затерти іншу - враховувати це при розробці onError (чи серіалізувати мутації);
  • згенеровані сервером поля (id, дати) в оптимістичному записі - тимчасові значення, які замінить відповідь;
  • повідомлення про відкат обов'язкове - тихе «повернення» стану користувач сприйме як баг.

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

Документація React пропонує думати про інтерфейс декларативно: не «що змінити після кліку», а «які візуальні стани бувають і що переводить з одного в інший».

Процес:

  1. перелічити візуальні стани: форма відповіді на питання - empty (поле порожнє), typing, submitting, error, success;
  2. визначити, що їх змінює: введення тексту, натискання «Надіслати», відповідь сервера (успіх чи помилка);
  3. описати стан мінімальним набором змінних без суперечностей;
  4. підключити обробники, що змінюють стан.

Редуктор як скінченний автомат:

function quizReducer(state, event) {
  switch (state.status) {
    case 'typing':
      if (event.type === 'submit') return { ...state, status: 'submitting' };
      if (event.type === 'change') return { ...state, answer: event.value };
      return state;
    case 'submitting':
      if (event.type === 'resolved') return { ...state, status: 'success' };
      if (event.type === 'rejected') return { ...state, status: 'typing', error: event.error };
      return state;   // під час відправки зміна тексту ігнорується
    case 'success':
      return state;   // кінцевий стан
    default:
      return state;
  }
}

Головна відмінність від звичайного редуктора - перевірка поточного стану перед обробкою події. Подія, недопустима в цьому стані, просто ігнорується: подвійне натискання «Надіслати» під час відправки не створить другий запит.

Що це дає:

  • неможливі переходи неможливі: не можна «надіслати» з success, змінити текст під час submitting;
  • інтерфейс - функція від стану: disabled={state.status === 'submitting'}, повідомлення про успіх лише в success;
  • легко тестувати: редуктор - чиста функція, перелік станів і переходів можна перевірити повністю;
  • легко побачити всі стани: для кожного з них можна відрендерити компонент окремо (наприклад, у Storybook) і перевірити дизайн.

Коли брати бібліотеку (XState): паралельні й вкладені стани, таймери й затримки як частина логіки, складні багатокрокові процеси (онбординг, оформлення замовлення з кількома способами оплати), потреба візуалізувати автомат для команди.

Для простих компонентів досить одного поля status замість кількох булевих прапорців - це вже більша частина користі.

Докладніше в документації: Реакція на введення через стан

Кеш серверних даних корисний, доки він актуальний. Є три джерела змін, і кожне потребує свого механізму.

1. Зміни, зроблені самим користувачем (мутації). Після успішного запиту кеш, що залежить від змінених даних, треба оновити:

useMutation({
  mutationFn: createOrder,
  onSuccess: (order) => {
    queryClient.invalidateQueries({ queryKey: ['orders'] });          // позначити застарілим і перезапитати
    queryClient.setQueryData(['orders', order.id], order);            // покласти відповідь у кеш одразу
  },
});

invalidateQueries з префіксом ключа (['orders']) зачіпає всі запити, ключ яких з нього починається: списки з різними фільтрами, сторінки пагінації. Тому ієрархічні ключі (['orders', { status }], ['orders', id]) важливі з самого початку.

2. Зміни, зроблені іншими (інші користувачі, фонові задачі).

  • повторний запит при поверненні на вкладку (refetchOnWindowFocus, увімкнено за замовчуванням) і з інтервалом (refetchInterval) - простий варіант без інфраструктури;
  • події в реальному часі - WebSocket чи SSE (у Laravel - Reverb + Echo). Подія від сервера інвалідує чи оновлює кеш:
useEffect(() => {
  const channel = echo.private(`team.${teamId}`);
  channel.listen('OrderShipped', (event) => {
    queryClient.setQueryData(['orders', event.order.id], event.order);
    queryClient.invalidateQueries({ queryKey: ['orders'], exact: false, refetchType: 'active' });
  });
  return () => echo.leave(`team.${teamId}`);
}, [teamId, queryClient]);

setQueryData чи invalidateQueries:

  • setQueryData - коли подія містить повні актуальні дані: без додаткового запиту;
  • invalidateQueries - коли подія лише сигналізує «щось змінилося»: менше даних у повідомленні, але запит на сервер. refetchType: 'active' перезапитує лише те, що зараз на екрані, решта оновиться при наступному використанні.

3. Застарівання за часом - staleTime. Для кожного типу даних - свій: довідники (міста, категорії) - години, список замовлень - секунди, курс валют - хвилина.

Пастки:

  • шторм запитів: подія, що інвалідує широкий ключ, у тисячі відкритих вкладок одночасно - тисячі запитів на сервер. Варто передавати дані в самій події або додавати випадкову затримку;
  • порядок подій: подія може прийти раніше, ніж відповідь на мутацію в цій самій вкладці, - перевіряти версію (updated_at) перед записом у кеш;
  • авторизація каналів: приватні канали WebSocket мають перевіряти, чи користувач має доступ до даних, - інакше кеш отримає чужі дані.

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