Питання на співбесіді з React
Питання з реальних співбесід з відповідями: Laravel і PHP, бази даних, JavaScript і фронтенд, Git, Docker, API, безпека й архітектура. Тими самими темами, що й тести.
100 питань
React Testing Library побудована на принципі: чим більше тести схожі на те, як застосунок використовують, тим більше впевненості вони дають. Тест взаємодіє з компонентом як користувач - бачить текст і кнопки, натискає, вводить, - а не перевіряє стан, props чи внутрішні методи.
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
test('додає товар у кошик', async () => {
const user = userEvent.setup();
render(<Product product={{ id: 1, title: 'Чашка', price: 250 }} />);
await user.click(screen.getByRole('button', { name: 'Додати в кошик' }));
expect(screen.getByRole('status')).toHaveTextContent('У кошику: 1');
});
Пріоритет запитів (від кращого до гіршого):
getByRoleзname- роль доступності (кнопка, посилання, поле, заголовок) і доступна назва. Так елементи знаходять і користувачі зчитувачів екрана;getByLabelText- поля форми за підписом;getByPlaceholderText,getByText,getByDisplayValue;getByAltText,getByTitle;getByTestId- лише коли інших способів немає.
Чому роль, а не клас чи data-testid:
- тест не ламається від рефакторингу: змінили розмітку, класи Tailwind, структуру - тест проходить, поки кнопка лишається кнопкою з тим самим текстом;
- тест перевіряє доступність: якщо кнопку зробили
<div onClick>,getByRole('button')її не знайде - і це правильно, бо клавіатурою та зчитувачем екрана її теж не використати; getByLabelTextпадає, якщо поле не пов'язане з підписом, - ще одна безкоштовна перевірка.
Варіанти запитів:
getBy...- елемент має бути, інакше помилка;queryBy...- повертаєnull, для перевірки відсутності (expect(screen.queryByText('Помилка')).not.toBeInTheDocument());findBy...- чекає на появу (асинхронно);getAllBy...тощо - кілька елементів.
screen замість деструктуризації результату render - коротше й не треба оновлювати деструктуризацію.
Налагодження: screen.debug() друкує поточний DOM, а screen.logTestingPlaygroundURL() дає посилання, де можна підібрати найкращий запит для елемента.
Докладніше в документації: Testing Library: пріоритет запитів
Обидва викликають події в тесті, але на різному рівні.
fireEvent відправляє одну конкретну подію DOM:
fireEvent.change(input, { target: { value: 'Оля' } });
fireEvent.click(button);
change одразу встановлює значення - без фокусу, без натискань клавіш, без подій keydown/input/keyup для кожної літери.
userEvent імітує дії користувача - послідовність подій, яку згенерував би браузер:
import userEvent from '@testing-library/user-event';
test('пошук', async () => {
const user = userEvent.setup();
render(<Search />);
await user.type(screen.getByRole('searchbox'), 'laravel');
await user.keyboard('{Enter}');
await user.click(screen.getByRole('button', { name: 'Очистити' }));
});
user.type для кожної літери викликає keydown, keypress, input, keyup, переводить фокус на поле кліком; user.click - pointerdown, mousedown, focus, pointerup, mouseup, click.
Чому userEvent кращий за замовчуванням:
- ловить реальні помилки: не дасть клікнути кнопку з
disabledчиpointer-events: none, не введе текст у поле лише для читання.fireEvent.clickспрацює на заблокованій кнопці - тест пройде, а користувач натиснути не зможе; - обробники, що реагують на
keydownчиfocus, отримують свої події; - вбудовані дії:
user.selectOptions,user.upload,user.tab()(перевірка порядку фокусу),user.hover,user.clear,user.paste.
Правила роботи з userEvent 14:
- створювати екземпляр через
userEvent.setup()на початку тесту (доrender); - усі методи асинхронні - обов'язково
await; - з фальшивими таймерами -
userEvent.setup({ advanceTimers: vi.advanceTimersByTime }), інакше тест «зависне» на внутрішніх затримках.
Коли fireEvent доречний:
- подія, яку
userEventне підтримує (scroll,resize, власні події, події медіа); - точне відтворення однієї події для перевірки обробника.
Обидва вже обгорнуті в act, тож оновлення стану після події застосовуються до наступної перевірки.
Vitest - тест-раннер на основі Vite: використовує ту саму конфігурацію (псевдоніми, плагіни, TypeScript, JSX), що й застосунок, тож окремо налаштовувати Babel чи трансформації не треба. API сумісний з Jest (describe, test, expect, vi.fn).
Встановлення:
npm i -D vitest jsdom @testing-library/react @testing-library/dom @testing-library/user-event @testing-library/jest-dom
@testing-library/dom - обов'язкова залежність, яку з версії 16 React Testing Library треба ставити явно.
Конфігурація:
// vite.config.ts (або vitest.config.ts)
export default defineConfig({
plugins: [react()],
test: {
environment: 'jsdom', // DOM для компонентів у Node.js
globals: true, // describe/test/expect без імпорту
setupFiles: ['./src/test/setup.ts'],
},
});
// src/test/setup.ts
import '@testing-library/jest-dom/vitest'; // toBeInTheDocument, toHaveTextContent...
jsdom - реалізація DOM на JavaScript: є document, події, форми. Немає реальної розкладки (getBoundingClientRect повертає нулі), IntersectionObserver, matchMedia - їх доводиться підміняти. Альтернатива - happy-dom (швидша, менш повна).
Очищення між тестами: React Testing Library автоматично демонтує компоненти після кожного тесту, якщо раннер має глобальний afterEach (з globals: true). Без глобальних функцій потрібен ручний afterEach(cleanup).
Мінімальний тест:
import { render, screen } from '@testing-library/react';
test('показує привітання', () => {
render(<Greeting name="Оля" />);
expect(screen.getByRole('heading')).toHaveTextContent('Привіт, Оля');
});
Корисні налаштування:
TypeScript: додати"types": ["vitest/globals", "@testing-library/jest-dom"]уtsconfig, щоб редактор знав глобальні функції й матчери;test.css: false(за замовчуванням CSS не обробляється) - тести не залежать від стилів;vitest --ui- зручний інтерфейс у браузері,vitest --coverage- покриття.
Jest працює з тими самими бібліотеками, але потребує окремого налаштування трансформацій (Babel чи ts-jest) і модулів - у проєктах на Vite Vitest простіший.
Докладніше в документації: React Testing Library: налаштування
useEffect - для синхронізації компонента з зовнішньою системою: підписка на WebSocket, таймер, сторонній віджет, ручна робота з DOM, запит даних.
useEffect(() => {
const connection = createConnection(roomId);
connection.connect();
return () => connection.disconnect(); // очищення
}, [roomId]); // залежності
Залежності: ефект перезапускається, коли змінилося будь-яке значення з масиву. В масиві мають бути всі реактивні значення, які ефект використовує, - лінтер react-hooks/exhaustive-deps за цим стежить. Порожній масив - лише після першого монтування.
Очищення виконується перед наступним запуском ефекту і при демонтуванні. Без нього - витоки: подвійні підписки, таймери, що живуть після компонента. У режимі розробки React навмисно монтує компонент двічі (StrictMode), щоб відсутність очищення стала помітною.
Коли ефект НЕ потрібен - найчастіша помилка:
- Похідні дані. Не
useEffect(() => setFullName(first + ' ' + last), [first, last]), а простоconst fullName = first + ' ' + lastпід час рендеру. Дороге обчислення -useMemo. - Реакція на дію користувача. Відправка форми, аналітика кліку - в обробнику події, а не в ефекті, що стежить за станом.
- Скидання стану при зміні props - через
keyна компоненті.
Запити даних в ефекті потребують обробки гонок (прапорець ignore в очищенні чи AbortController). У реальних застосунках для цього використовують TanStack Query, SWR або засоби фреймворку.
useRef(initial) повертає об'єкт { current }, який живе весь час існування компонента. На відміну від стану, зміна ref.current не викликає рендер.
Два застосування:
- Доступ до DOM-елемента:
function Search() {
const inputRef = useRef(null);
return (
<>
<input ref={inputRef} />
<button onClick={() => inputRef.current.focus()}>Шукати</button>
</>
);
}
- Значення, яке треба зберігати між рендерами, але не показувати: ID таймера, попереднє значення, прапорець «чи вже відправлено», екземпляр сторонньої бібліотеки.
const timerRef = useRef(null);
function start() {
timerRef.current = setInterval(tick, 1000);
}
function stop() {
clearInterval(timerRef.current);
}
Стан чи ref:
- Значення впливає на те, що показано, - стан.
- Значення потрібне лише логіці й не має перемальовувати компонент - ref.
Правила:
- Не читати й не записувати
ref.currentпід час рендеру (крім лінивої ініціалізації) - рендер має бути чистим, а зміна ref не відображається в UI. - Для передачі ref у власний компонент у React 19
ref- звичайний prop. Раніше для цього потрібен бувforwardRef.
useReducer виносить логіку оновлення стану в окрему функцію-редуктор: компонент лише описує, що сталося (дію), а редуктор вирішує, як змінюється стан.
function cartReducer(state, action) {
switch (action.type) {
case 'added':
return { ...state, items: [...state.items, action.item] };
case 'removed':
return { ...state, items: state.items.filter((i) => i.id !== action.id) };
case 'cleared':
return { ...state, items: [] };
default:
throw new Error(`Невідома дія: ${action.type}`);
}
}
function Cart() {
const [state, dispatch] = useReducer(cartReducer, { items: [] });
return <button onClick={() => dispatch({ type: 'cleared' })}>Очистити</button>;
}
Коли useReducer кращий:
- кілька пов'язаних значень змінюються разом (статус запиту, дані, помилка; кроки майстра);
- багато різних оновлень одного стану розкидано по обробниках - редуктор збирає їх в одному місці;
- наступний стан залежить від попереднього складним чином;
- тестування: редуктор - чиста функція, її тестують без React:
expect(cartReducer(state, action)).toEqual(...); - передача вниз:
dispatchстабільний між рендерами - його безпечно передавати в дочірні компоненти й контекст безuseCallback.
Коли досить useState: незалежні прості значення (відкрито/закрито, текст у полі).
Правила редуктора:
- чистий: без запитів, таймерів, випадкових значень - React може викликати його двічі в StrictMode;
- не мутує стан - повертає новий об'єкт;
- дії описують подію (
'added','login_failed'), а не «встановити поле» - тоді логіка зосереджена в редукторі, а не в компонентах.
Лінива ініціалізація - третій аргумент: useReducer(reducer, userId, createInitialState) - функція викличеться лише один раз.
Масштабування: useReducer + Context - простий спосіб керувати станом частини застосунку без зовнішніх бібліотек. Для складнішого - Redux Toolkit, ідеї якого побудовані на тих самих редукторах.
Обидва хуки мають однаковий API, різниця - коли вони виконуються відносно відмальовування екрана.
useEffect- після того, як браузер відмалював зміни. Не блокує показ - підходить для більшості ефектів (запити, підписки, логування);useLayoutEffect- після оновлення DOM, але до відмальовування. Браузер чекає, поки ефект виконається.
Коли потрібен useLayoutEffect: коли треба виміряти DOM і одразу на основі вимірювання змінити розмітку - щоб користувач не побачив проміжного стану.
Класичний приклад - підказка (tooltip), яка має з'явитися над елементом, а якщо зверху немає місця - під ним:
function Tooltip({ targetRect, children }) {
const ref = useRef(null);
const [tooltipHeight, setTooltipHeight] = useState(0);
useLayoutEffect(() => {
setTooltipHeight(ref.current.getBoundingClientRect().height);
}, []);
const top = targetRect.top - tooltipHeight < 0
? targetRect.bottom
: targetRect.top - tooltipHeight;
return <div ref={ref} style={{ position: 'fixed', top }}>{children}</div>;
}
З useEffect користувач на мить побачив би підказку не на тому місці, а потім вона «стрибнула» б. З useLayoutEffect вимірювання й перерахунок відбуваються до першого показу.
Інші випадки: відновлення позиції прокрутки, анімації, що стартують від виміряної позиції, синхронізація зі сторонніми бібліотеками, що вимірюють DOM.
Ціна: useLayoutEffect блокує відмальовування. Важка робота в ньому робить інтерфейс повільнішим. За замовчуванням - useEffect, а useLayoutEffect - лише коли видно «мерехтіння».
Серверний рендер: на сервері немає DOM і розкладки - ні useEffect, ні useLayoutEffect там не виконуються. Компонент, що залежить від вимірювань, на сервері рендериться без них; варіанти - показувати його лише на клієнті або мати розумний вигляд за замовчуванням.
useInsertionEffect - ще раніший хук, лише для бібліотек CSS-in-JS, що вставляють стилі до будь-яких вимірювань.
Проблема. Ефект має перезапускатися, коли змінюються одні значення, але використовує й інші, через які перезапускатися не повинен.
function ChatRoom({ roomId, theme }) {
useEffect(() => {
const connection = createConnection(roomId);
connection.on('connected', () => {
showNotification('Підключено', theme); // потрібна поточна тема
});
connection.connect();
return () => connection.disconnect();
}, [roomId, theme]); // зміна теми перепідключає до чату!
}
Прибрати theme із залежностей не можна - буде застаріле значення, і лінтер це помітить. Додати - зайві перепідключення.
useEffectEvent (стабільний з React 19.2) виносить нереактивну частину логіки в «подію ефекту»: функцію, яка завжди бачить актуальні props і стан, але не є залежністю:
import { useEffect, useEffectEvent } from 'react';
function ChatRoom({ roomId, theme }) {
const onConnected = useEffectEvent(() => {
showNotification('Підключено', theme);
});
useEffect(() => {
const connection = createConnection(roomId);
connection.on('connected', () => onConnected());
connection.connect();
return () => connection.disconnect();
}, [roomId]); // лише справжня причина перепідключення
}
Як розрізняти:
- реактивна логіка (має перезапускати синхронізацію) - у тілі ефекту, її значення в залежностях;
- логіка-реакція (виконується в певний момент і має бачити свіжі значення) - у
useEffectEvent.
Інші типові випадки: аналітика з поточними даними користувача при зміні сторінки, обробник інтервалу, що читає свіжий стан, колбек з props (onChange), який батько щоразу створює заново.
Обмеження:
- викликати лише з ефектів - не з обробників подій і не під час рендеру;
- не передавати в інші компоненти й хуки - функція оголошується поруч з ефектом, що її використовує;
- це не спосіб «приглушити» лінтер: якщо значення справді має перезапускати ефект, воно лишається в залежностях.
До появи хука це розв'язували через useRef з останнім значенням колбеку - useEffectEvent робить те саме офіційно й безпечніше для конкурентного рендерингу.
Компонент рендериться повторно, коли:
- змінився його стан (
useState,useReducer); - змінився контекст, який він читає;
- перерендерився батько - за замовчуванням разом з ним рендеряться всі дочірні компоненти, незалежно від того, чи змінилися їхні props.
Третій пункт часто дивує. Але рендер - це лише виклик функції й порівняння віртуального DOM; реальний DOM змінюється тільки там, де щось справді змінилося. Зазвичай це дешево.
React.memo пропускає рендер компонента, якщо його props поверхнево не змінилися (Object.is для кожного prop):
const Chart = memo(function Chart({ data, onSelect }) {
/* важкий рендер */
});
Чому memo часто «не працює»: батько передає новий об'єкт чи функцію на кожен рендер:
<Chart data={rows.filter(isVisible)} onSelect={(id) => select(id)} />
// і data, і onSelect - нові посилання щоразу, memo марний
Тут потрібні useMemo для data і useCallback для onSelect - або передавати примітиви.
Коли memo справді доречний: компонент рендериться помітно довго і часто перерендерюється з тими самими props - великі списки, графіки, редактори. Для дрібних компонентів порівняння props може коштувати стільки ж, скільки рендер.
Альтернатива без мемоізації - правильна структура: опустити стан нижче, до компонента, якому він потрібен, або передати важку частину як children - тоді вона не перерендериться разом із батьком.
React Compiler (стабільний з 2025 року) автоматично мемоізує компоненти й значення, і там, де його ввімкнено, ручні memo/useMemo/useCallback здебільшого не потрібні.
useMemo(fn, deps)- кешує результат обчислення між рендерами, доки залежності не змінились.useCallback(fn, deps)- кешує саму функцію (те саме посилання). По суті,useMemo(() => fn, deps).
const visible = useMemo(
() => todos.filter((t) => matches(t, filter)),
[todos, filter],
);
const handleSelect = useCallback((id) => setSelected(id), []);
Коли вони потрібні:
- Справді дороге обчислення - фільтрація чи сортування тисяч елементів, побудова дерева. Перевірити легко:
console.timeнавколо обчислення; якщо це мілісекунди й більше - є сенс. - Стабільне посилання для дочірнього компонента з
memo- інакше нові об'єкт чи функція на кожен рендер скасовують мемоізацію. - Значення в залежностях ефекту - функція чи об'єкт, створені заново на кожен рендер, перезапускали б
useEffectщоразу.
Коли зайві:
- Прості обчислення (
a + b,items.length, форматування рядка) - мемоізація коштує більше за них. - Колбеки для звичайних DOM-елементів (
<button onClick={...}>) - кнопці байдуже, нова це функція чи ні. - «Про всяк випадок» скрізь - код важче читати, а неправильні залежності створюють баги зі застарілими значеннями.
Важливо: useMemo - оптимізація, а не гарантія. React може скинути кеш, тож код має працювати правильно й без нього.
React Compiler робить цю мемоізацію автоматично на етапі збирання. У проєктах, де він увімкнений, ручні useMemo і useCallback здебільшого прибирають.
Стан зберігається не «в компоненті», а в позиції компонента в дереві. React зберігає стан, доки на тому самому місці дерева рендериться той самий тип компонента.
{isFancy ? <Counter fancy /> : <Counter />}
Перемикання isFancy не скидає лічильник: на тому самому місці той самий тип Counter, змінилися лише props.
Стан скидається, коли:
- на тому самому місці з'являється інший тип компонента (
<Counter />→<Timer />) або інший тег (<div>→<section>- скидається все піддерево); - компонент прибрано з дерева і повернуто;
- змінився
key.
key для скидання стану - найважливіший практичний прийом:
<ContactForm key={contact.id} contact={contact} />
Перемкнули контакт - змінився key - React створює новий екземпляр форми з чистим станом. Без key форма зберегла б введений текст від попереднього контакту.
Це заміна поширеному антипатерну «скинути стан в ефекті при зміні props»:
// погано: зайвий рендер зі старими даними, потім скидання
useEffect(() => { setDraft(''); }, [contact.id]);
Пастка - компонент, оголошений усередині компонента:
function Page() {
const [text, setText] = useState('');
function Field() { // нова функція на кожному рендері Page
return <input value={text} onChange={(e) => setText(e.target.value)} />;
}
return <Field />;
}
На кожному рендері Field - новий тип компонента, тож React розмонтовує старий і монтує новий: поле губить фокус після кожної літери. Компоненти оголошують на верхньому рівні модуля.
Зворотний бік - стан «прилипає», коли це не очікується: однакова позиція в різних гілках умови, однаковий key у різних списках. Тоді допомагає різний key для гілок.
Зберегти стан прихованого компонента (вкладки, бічна панель) - не прибирати його з дерева: CSS-приховування, <Activity mode="hidden"> або винести стан угору до батька.
Портал рендерить дочірні елементи в інший вузол DOM, ніж той, де знаходиться компонент:
import { createPortal } from 'react-dom';
function Modal({ open, onClose, children }) {
if (!open) return null;
return createPortal(
<div className="fixed inset-0 grid place-items-center bg-black/50" onClick={onClose}>
<div role="dialog" aria-modal="true" onClick={(e) => e.stopPropagation()}>
{children}
</div>
</div>,
document.body,
);
}
Навіщо: модальні вікна, підказки, випадні меню мають бути поверх усього. Якщо їхня розмітка глибоко в дереві, батьківські стилі заважають: overflow: hidden обрізає, transform ламає position: fixed, z-index обмежений контекстом накладання батька.
Ключова особливість - події. Портал змінює місце в DOM, але не в дереві React. Події спливають за деревом React:
<div onClick={() => console.log('клік у батьку')}>
<Modal open>
<button>Кнопка у вікні</button> {/* клік спливе до div батька! */}
</Modal>
</div>
Клік по кнопці, яка в DOM лежить у body, все одно викличе onClick батька. Те саме з контекстом: портал бачить провайдери свого предка в React, а не за місцем у DOM.
Це зручно (стан, контекст і обробники працюють як для звичайних дітей), але інколи несподівано - наприклад, «клік поза меню» через contains() у DOM вважатиме клік у порталі зовнішнім.
Що ще потрібно для якісного модального вікна (портал цього не дає):
- фокус усередині вікна і повернення фокусу після закриття;
- закриття по Escape;
- блокування прокрутки сторінки;
aria-modal,aria-labelledby.
Нативна альтернатива - <dialog> з showModal(): браузер сам виносить вікно на верхній шар (без порталу й боротьби із z-index), дає фокус і Escape. Для багатьох випадків простіше за портал.
SSR: на сервері порталів у document.body немає - модальні вікна зазвичай рендерять лише на клієнті (після монтування).
Без обробки помилка під час рендеру будь-якого компонента розмонтовує весь застосунок - користувач бачить порожню сторінку. Межа помилок перехоплює помилки свого піддерева й показує запасний інтерфейс.
Межі помилок досі пишуться лише класами - хука-аналога немає:
class ErrorBoundary extends React.Component {
state = { error: null };
static getDerivedStateFromError(error) {
return { error }; // наступний рендер покаже fallback
}
componentDidCatch(error, info) {
reportError(error, info.componentStack); // у моніторинг
}
render() {
if (this.state.error) return this.props.fallback;
return this.props.children;
}
}
<ErrorBoundary fallback={<p>Віджет не завантажився</p>}>
<RevenueChart />
</ErrorBoundary>
На практиці частіше беруть готовий пакет react-error-boundary - з функцією скидання (resetErrorBoundary), ключами для автоматичного скидання і хуком useErrorBoundary.
Що межа помилок ловить: помилки під час рендеру, у хуках життєвого циклу, в конструкторах дочірніх компонентів - усе, що відбувається, коли React викликає ваш код у дереві. Також відхилений проміс, прочитаний через use().
Чого НЕ ловить:
- обробники подій (
onClick) - там звичайнийtry/catch; - асинхронний код -
setTimeout, проміси безuse(), колбеки запитів; - помилки в самій межі - вони йдуть до наступної межі вище;
- серверний рендер - на сервері помилки обробляються інакше.
Щоб передати помилку з обробника чи асинхронного коду в межу, її «кидають» під час рендеру: зберегти в стан і throw у рендері, або showBoundary(error) з react-error-boundary.
Де ставити межі:
- верхня - на весь застосунок, щоб замість білого екрана був зрозумілий текст і кнопка оновлення;
- навколо незалежних віджетів - графік, коментарі, рекомендації: зламаний віджет не зносить сторінку;
- на рівні маршруту - роутери (React Router, Next.js
error.tsx) мають власні межі.
React 19: опції кореня onCaughtError і onUncaughtError у createRoot - централізоване логування помилок, перехоплених межами і неперехоплених.
Клієнтський стан належить інтерфейсу: відкрите модальне вікно, текст у полі, обрана вкладка. Він живе в браузері, і застосунок - його єдиний власник.
Серверний стан - копія даних, що живуть на сервері: список замовлень, профіль, вакансії. Його особливості:
- може змінитися без участі вашого застосунку (інший користувач, фонова задача);
- потребує завантаження, кешування, повторних запитів, обробки помилок;
- може бути застарілим у будь-який момент.
Зберігати його в 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.
Питання з реальних технічних співбесід - 100 питань у 8 темах, розібраних із відповідями. Нижче - розбивка за рівнями та темами, якщо хочете звузити підготовку.
- Теми
- Хуки 15 Рендеринг 15 Стан і дані 12 Форми й Actions 12 Next.js, Inertia й маршрутизація 12 TypeScript та інструменти 12 Продуктивність 12 Тестування 10
Готуєтесь до співбесіди не просто так: зараз на сайті 145 відкритих вакансій Laravel і PHP. Переглянути вакансії