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

Senior: питання на співбесіді з теми «Стан і дані»

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

4 питання

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

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: інвалідація запитів