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
});
Кроки й навіщо кожен:
- скасувати поточні запити списку - інакше відповідь, що вже летить, перезапише оптимістичні дані старими;
- зберегти знімок для відкату;
- оновити кеш - інтерфейс змінюється миттєво;
- при помилці - відкотити до знімка і повідомити користувача;
- після завершення - перезапитати дані з сервера, щоб кеш точно відповідав реальності.
Простіший варіант - через змінні мутації: не чіпати кеш, а в компоненті показувати mutation.variables, поки мутація в стані isPending. Менше коду, відкат автоматичний (просто зникає тимчасове відображення), але зміна видна лише там, де рендериться мутація.
React 19 useOptimistic - схожа ідея для дій і форм без бібліотеки: тимчасовий стан, що діє до завершення асинхронної дії.
Коли оптимізм доречний: дія майже завжди успішна, легко відкочується і не має незворотних наслідків.
Коли ні: оплати, відправка листів, видалення без можливості відновлення, операції з високим шансом відмови через валідацію - там краще чесний стан «Зберігаємо...».
Пастки:
- кілька швидких мутацій однієї сутності: відкат однієї може затерти іншу - враховувати це при розробці
onError(чи серіалізувати мутації); - згенеровані сервером поля (id, дати) в оптимістичному записі - тимчасові значення, які замінить відповідь;
- повідомлення про відкат обов'язкове - тихе «повернення» стану користувач сприйме як баг.
Докладніше в документації: TanStack Query: оптимістичні оновлення
Документація React пропонує думати про інтерфейс декларативно: не «що змінити після кліку», а «які візуальні стани бувають і що переводить з одного в інший».
Процес:
- перелічити візуальні стани: форма відповіді на питання -
empty(поле порожнє),typing,submitting,error,success; - визначити, що їх змінює: введення тексту, натискання «Надіслати», відповідь сервера (успіх чи помилка);
- описати стан мінімальним набором змінних без суперечностей;
- підключити обробники, що змінюють стан.
Редуктор як скінченний автомат:
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: інвалідація запитів