Питання на співбесіді з React
Питання з реальних співбесід з відповідями: Laravel і PHP, бази даних, JavaScript і фронтенд, Git, Docker, API, безпека й архітектура. Тими самими темами, що й тести.
100 питань
Звичайні необов'язкові props дозволяють будь-які комбінації, навіть безглузді:
type ButtonProps = {
href?: string; // посилання
onClick?: () => void; // кнопка
external?: boolean; // має сенс лише з href
};
<Button onClick={save} external /> // компілюється, хоча безглуздо
<Button /> // ні дії, ні посилання
Дискримінований union описує кожен допустимий варіант окремо:
type LinkProps = { as: 'link'; href: string; external?: boolean };
type ActionProps = { as: 'button'; onClick: () => void; disabled?: boolean };
type ButtonProps = (LinkProps | ActionProps) & { children: ReactNode };
function Button(props: ButtonProps) {
if (props.as === 'link') {
return (
<a href={props.href} target={props.external ? '_blank' : undefined}>
{props.children}
</a>
);
}
return (
<button onClick={props.onClick} disabled={props.disabled}>
{props.children}
</button>
);
}
<Button as="link" href="/jobs">Вакансії</Button>
<Button as="button" onClick={save}>Зберегти</Button>
<Button as="button" href="/x">...</Button> // помилка: href не існує для button
Варіант без явного дискримінатора - через never, щоб props взаємно виключали один одного:
type Props =
| { value: string; defaultValue?: never; onChange: (v: string) => void } // керований
| { defaultValue?: string; value?: never; onChange?: never }; // некерований
Передати одночасно value і defaultValue неможливо - типова помилка з полями форм.
Типові застосування:
- керований / некерований компонент;
- компонент стану запиту:
{ status: 'loading' } | { status: 'error'; error: string } | { status: 'success'; data: T }; - модальне вікно з різними наборами кнопок;
- поле форми, де
optionsобов'язкові лише для типуselect.
Пастки:
- деструктуризація в параметрах губить звуження:
function Button({ as, href, onClick })-hrefне існує у варіантіbutton, TypeScript не дасть деструктуризувати. Звужувати треба поprops.as, а деструктуризувати вже всередині гілки; ...restз union дає перетин полів і може «загубити» варіант - передаючи props далі, краще явно перелічувати;- повідомлення про помилки для великих union стають довгими - варто тримати варіантів небагато й давати їм імена.
Докладніше в документації: Звуження типів: дискриміновані union
Створення проєкту:
npm create vite@latest my-app -- --template react-ts
Шаблон містить vite.config.ts з плагіном @vitejs/plugin-react, tsconfig для коду застосунку й окремо для конфігурації, ESLint з eslint-plugin-react-hooks.
Що робить @vitejs/plugin-react:
- перетворює JSX (автоматичний runtime - імпорт
Reactу файлах не потрібен); - Fast Refresh - зміна компонента оновлює лише його, зі збереженням стану. Працює, якщо файл експортує лише компоненти: експорт констант чи функцій поряд з компонентом змушує перезавантажувати модуль повністю;
- у Vite 8 перетворення робить швидкий компілятор на Rust (Oxc), Babel за замовчуванням не потрібен.
React Compiler у поточній версії плагіна підключається двома способами:
// 1. через Babel - стабільний варіант
import react, { reactCompilerPreset } from '@vitejs/plugin-react';
import babel from '@rolldown/plugin-babel';
export default defineConfig({
plugins: [react(), babel({ presets: [reactCompilerPreset()] })],
});
// 2. нативний порт на Rust - експериментальний
export default defineConfig({
plugins: [react({ compiler: true })],
});
Компілятор 1.0 стабільний, працює з React 17+ і додає мемоізацію на етапі збирання. Для поступового впровадження - compilationMode: 'annotation' (компілюються лише функції з директивою "use memo").
TypeScript важливо розуміти правильно:
- Vite не перевіряє типи - він лише видаляє їх під час перетворення. Збирання пройде навіть з помилками типів;
- перевірка - окремим кроком:
tsc -bу скриптіbuild(так у шаблоні) і в CI, а під час розробки - редактор чиvite-plugin-checker; "jsx": "react-jsx","moduleResolution": "bundler","strict": true,"noUncheckedIndexedAccess"- корисні налаштування для нового проєкту;"isolatedModules": trueпотрібен, бо кожен файл перетворюється окремо:export typeдля реекспорту типів.
Змінні оточення - лише з префіксом VITE_, через import.meta.env; типи - в vite-env.d.ts.
ESLint з eslint-plugin-react-hooks (конфігурація recommended) у версії 7 містить і правила хуків, і правила React Compiler - порушення, через які компілятор пропустив би компонент, видно ще в редакторі.
З Laravel - laravel-vite-plugin поряд з плагіном React, сторінки через Inertia; стартовий набір Laravel React вже містить це налаштування.
eslint-plugin-react-hooks - офіційний плагін ESLint від команди React. З версії 6-7 він містить не лише два класичні правила хуків, а й правила, побудовані на аналізі React Compiler.
// eslint.config.js
import reactHooks from 'eslint-plugin-react-hooks';
import { defineConfig } from 'eslint/config';
export default defineConfig([
reactHooks.configs.flat.recommended,
]);
Класичні правила:
rules-of-hooks- хуки лише на верхньому рівні компонентів і власних хуків, не в умовах і циклах;exhaustive-deps- усі значення, використані вuseEffect/useMemo/useCallback, мають бути в залежностях.
Правила компілятора (у конфігурації recommended) ловлять порушення правил React, через які компонент працюватиме неправильно або компілятор його пропустить:
purity- нечисті виклики під час рендеру (Math.random(),Date.now()у тілі компонента);immutability- мутація props, стану чи значень з хуків;refs- читання чи записref.currentпід час рендеру;set-state-in-renderіset-state-in-effect- викликsetStateпрямо в рендері (нескінченний цикл) чи синхронно в ефекті (зайвий рендер; часто стан можна обчислити під час рендеру);static-components- оголошення компонента всередині іншого компонента (він перестворюється щоразу й губить стан);globals- зміна глобальних змінних під час рендеру;preserve-manual-memoization- ручнийuseMemo/useCallback, який компілятор не може зберегти;incompatible-library- бібліотеки з API, несумісним з мемоізацією компілятора;error-boundaries,use-memo,unsupported-syntax,config,gating.
Конфігурація recommended-latest додає експериментальні правила.
Чому це важливо навіть без компілятора: кожне з цих порушень - справжній баг або джерело зайвих рендерів незалежно від того, чи увімкнено компілятор. А з компілятором ESLint показує, які компоненти він пропустить і чому, - ще в редакторі.
Як упроваджувати в старий проєкт:
- увімкнути правила й подивитися кількість порушень - це оцінка, наскільки код готовий до компілятора;
- виправляти поступово, починаючи з
purity,immutability,refs- вони вказують на реальні помилки; - не вимикати правило коментарем без пояснення:
eslint-disableдляexhaustive-deps- найчастіше маскування справжньої проблеми із застарілими значеннями (stale closure).
Увімкнути компілятор на великому існуючому проєкті одним кроком ризиковано: код, що порушує правила React, але «випадково працював», може поводитися інакше після мемоізації. Документація радить поступове впровадження.
Три способи обмежити область дії:
1. За каталогами - через overrides Babel:
// babel.config.js
module.exports = {
overrides: [
{ test: './src/features/checkout/**/*.{js,jsx,ts,tsx}', plugins: ['babel-plugin-react-compiler'] },
],
};
2. Режим анотацій - компілюються лише функції з директивою:
['babel-plugin-react-compiler', { compilationMode: 'annotation' }]
function ProductGrid({ products }) {
'use memo'; // лише цей компонент оптимізується
// ...
}
3. Runtime-гейтинг (gating) - компілятор генерує обидві версії, і вибір між ними робить прапорець функцій: можна порівняти метрики на частині користувачів.
Виключення компонента - "use no memo":
function LegacyDataGrid(props) {
'use no memo'; // TODO: прибрати після виправлення мутації props у сортуванні (JIRA-123)
// ...
}
Директиви - тимчасові аварійні виходи: документація радить пояснювати причину коментарем і планувати їх видалення.
Режими компіляції: infer (за замовчуванням - компілятор сам визначає компоненти й хуки за іменуванням), annotation, all; директиви перевизначають рішення режиму.
Типові причини проблем після ввімкнення:
- мутація props чи стану - раніше рендер «підхоплював» зміну, тепер мемоізований результат лишається старим;
- читання
ref.currentпід час рендеру; - бібліотеки з власним механізмом відстеження змін (інкрементальні сховища, деякі бібліотеки форм і таблиць) - правило
incompatible-libraryпопереджає про відомі випадки; - ефекти, що залежали від нестабільних залежностей і раніше спрацьовували частіше.
Порядок дій:
- увімкнути правила компілятора в
eslint-plugin-react-hooksі виправити порушення; - ввімкнути компілятор на частині коду з хорошими тестами;
- перевірити в DevTools, які компоненти оптимізовано (мітка «Memo ✨»);
- порівняти метрики (INP, час рендеру), розширювати область.
Існуючі useMemo/useCallback не обов'язково прибирати одразу - компілятор їх враховує, а правило preserve-manual-memoization попередить, якщо не зможе зберегти ручну мемоізацію.
React вирішує, чи зберегти компонент між рендерами, за двома ознаками: позиція в дереві і тип (функція-компонент). Якщо на тій самій позиції опинився інший тип - старий компонент знищується разом зі станом і DOM, новий створюється з нуля.
Типова помилка:
function VacancyPage({ vacancy }) {
const [tab, setTab] = useState('description');
// нова функція на КОЖНОМУ рендері VacancyPage
function ApplyForm() {
const [email, setEmail] = useState('');
return <input value={email} onChange={(e) => setEmail(e.target.value)} />;
}
return (
<>
<Tabs value={tab} onChange={setTab} />
<ApplyForm />
</>
);
}
Кожен рендер VacancyPage створює нову функцію ApplyForm - для React це новий тип компонента. Наслідки:
- стан скидається: введений email зникає при перемиканні вкладки;
- DOM перестворюється - втрачається фокус, позиція курсору, анімації;
- дорожче: монтування з нуля замість оновлення.
Рішення: оголошувати компоненти на верхньому рівні модуля і передавати дані через props. Правило static-components з eslint-plugin-react-hooks ловить цю помилку автоматично.
Та сама механіка свідомо - key для скидання стану:
<ProfileForm key={userId} user={user} />
Зміна key - новий компонент: форма скидається при переході до іншого користувача. Це краще, ніж синхронізувати стан з props в ефекті.
Але key має ціну. Перемонтування - це знищення й створення всього піддерева: ефекти очищення й запуску, нові запити, втрата стану в усіх нащадках. Зміна key на великому дереві без потреби (наприклад, key={Math.random()} чи key={JSON.stringify(filters)} на всій сторінці) - джерело повільності й «миготіння».
Інші випадки втрати стану через позицію:
- умовний рендер, що змінює структуру:
{isEditing ? <Form /> : <Preview />}на тій самій позиції - стан форми зникає при перемиканні. Якщо стан треба зберегти - тримати його вище чи ховати компонент CSS; - різні обгортки:
<div><Counter /></div>проти<section><Counter /></section>- інший тип батька, лічильник скидається; - індекс у
keyсписку - при видаленні першого елемента стан «переїжджає» на сусідні.
Правило: тип і позиція компонента мають бути стабільними, а скидання стану - явним, через key.
При серверному рендері (Next.js, Inertia SSR, React Router) сторінка приходить готовим HTML і швидко з'являється. Але інтерактивною вона стає лише після гідратації: браузер завантажує JavaScript, React заново виконує всі компоненти й «прив'язує» обробники подій до існуючого HTML.
import { hydrateRoot } from 'react-dom/client';
hydrateRoot(document.getElementById('root')!, <App />);
Чому це дорого:
- завантаження й розбір JavaScript усіх компонентів сторінки, навіть статичних;
- повторне виконання всіх компонентів на клієнті - обсяг роботи як у першого клієнтського рендеру;
- поки гідратація триває, кліки не працюють або обробляються із затримкою (погіршується INP).
Як прискорити:
1. Server Components (у фреймворках, що їх підтримують) - компоненти, які виконуються лише на сервері. Їхній код не потрапляє в бандл і не гідратується. Клієнтськими ('use client') лишаються лише інтерактивні частини.
2. Вибіркова гідратація через Suspense:
<Layout>
<Header />
<Suspense fallback={<CommentsSkeleton />}>
<Comments />
</Suspense>
</Layout>
Межі Suspense дають React змогу гідратувати частини незалежно: з потоковим рендером (renderToReadableStream/renderToPipeableStream) вміст надходить порціями, а при кліку на ще не гідратовану частину React пріоритетно гідратує саме її.
3. Менше JavaScript на сторінці: розділення коду, ліниве завантаження компонентів нижче першого екрана, легші бібліотеки.
4. Розбіжності гідратації (hydration mismatch) - HTML з сервера не збігається з першим клієнтським рендером (дата й час, Math.random(), window у рендері, розширення браузера змінили DOM). React 19 показує зрозумілу різницю в консолі, але виправлення - перерендер частини дерева на клієнті, тобто додаткова робота. Для неминучих відмінностей (час) - suppressHydrationWarning точково, для залежного від браузера - рендер після монтування.
5. Не гідратувати те, що не інтерактивне. Якщо сторінка переважно статична, «острівці» інтерактивності (окремі createRoot для віджетів) чи Server Components дешевші, ніж гідратація всього документа.
Метрики: Total Blocking Time і INP у Lighthouse/вебвіталсах, вкладка Performance - довгі задачі під час гідратації.
Повільна сторінка часто повільна не через рендер, а через водоспад завантажень: компонент рендериться - лише тоді дізнається, що потрібен шрифт, скрипт чи дані - запитує - чекає. React 19 додав API, щоб компонент міг оголосити ресурс заздалегідь.
Функції з react-dom:
import { preload, preinit, preconnect, prefetchDNS } from 'react-dom';
function ProductPage({ product }) {
preconnect('https://cdn.example.com'); // відкрити з'єднання
preload(product.heroImage, { as: 'image', fetchPriority: 'high' }); // завантажити, не виконувати
preinit('https://maps.example.com/sdk.js', { as: 'script' }); // завантажити й виконати
preload('/fonts/Inter.woff2', { as: 'font', type: 'font/woff2', crossOrigin: 'anonymous' });
return <Gallery images={product.images} />;
}
prefetchDNS- лише розв'язати домен;preconnect- встановити з'єднання (DNS + TLS);preload- завантажити ресурс (зображення, шрифт, стиль, скрипт), щоб він був у кеші, коли знадобиться;preinit- завантажити й застосувати скрипт чи таблицю стилів;preloadModule/preinitModule- те саме для ES-модулів.
Як це працює: виклики можна робити прямо під час рендеру чи в обробниках подій. При серверному рендері React виводить відповідні <link rel="preload"> у <head> якомога раніше в потоці HTML, а повторні виклики для того самого ресурсу дедуплікуються.
Метадані й стилі прямо в компонентах:
function Article({ post }) {
return (
<article>
<title>{post.title}</title>
<meta name="description" content={post.excerpt} />
<link rel="stylesheet" href="/css/article.css" precedence="default" />
...
</article>
);
}
React 19 піднімає <title>, <meta>, <link> у <head> документа. Таблиці стилів з precedence завантажуються до показу вмісту, що від них залежить (без «миготіння» нестилізованого вмісту), і вставляються в правильному порядку.
Практичні застосування:
- зображення першого екрана (LCP) -
preloadз високим пріоритетом; - попереднє завантаження при наведенні:
onMouseEnter={() => preload(nextPageImage, { as: 'image' })}; - сторонні SDK (карти, оплата) -
preconnectзаздалегідь,preinitлише на сторінках, де потрібні.
Обережно: зайвий preload конкурує за пропускну здатність з дійсно критичними ресурсами. Попередньо завантажувати варто те, що точно знадобиться найближчим часом.
Докладніше в документації: API попереднього завантаження ресурсів
У React 19 форма може передати функцію-дію в action, а useActionState дає результат дії й стан очікування:
function ApplyForm({ submit }: { submit: (email: string) => Promise<string | null> }) {
const [error, formAction, isPending] = useActionState(async (_prev: string | null, formData: FormData) => {
return submit(String(formData.get('email'))); // null - успіх, рядок - помилка
}, null);
return (
<form action={formAction}>
<label>
Email
<input name="email" type="email" />
</label>
<button disabled={isPending}>{isPending ? 'Надсилаємо...' : 'Надіслати'}</button>
{error && <p role="alert">{error}</p>}
</form>
);
}
Тест - як дії користувача, з очікуванням результату:
test('показує помилку від сервера', async () => {
const user = userEvent.setup();
const submit = vi.fn().mockResolvedValue('Ця адреса вже подала заявку');
render(<ApplyForm submit={submit} />);
await user.type(screen.getByLabelText('Email'), 'olia@example.com');
await user.click(screen.getByRole('button', { name: 'Надіслати' }));
expect(await screen.findByRole('alert')).toHaveTextContent('Ця адреса вже подала заявку');
expect(submit).toHaveBeenCalledWith('olia@example.com');
});
Що відрізняється від форм з onSubmit:
- дія асинхронна й виконується в переході (transition) - результат з'являється не одразу після кліку, тому
findBy/waitFor, а не синхронна перевірка; - поля некеровані - значення беруться з
FormData, тож у тесті потрібен реальнийnameна полі й введення черезuserEvent; - після успішної дії React скидає некеровану форму - поля очищаються. Це варто перевірити, якщо поведінка важлива.
Стан очікування перевіряється, якщо дія не завершується одразу:
let resolve!: (v: string | null) => void;
const submit = vi.fn(() => new Promise<string | null>((r) => (resolve = r)));
render(<ApplyForm submit={submit} />);
await user.click(screen.getByRole('button'));
expect(await screen.findByRole('button', { name: 'Надсилаємо...' })).toBeDisabled();
resolve(null);
await waitFor(() => expect(screen.getByRole('button', { name: 'Надіслати' })).toBeEnabled());
Server Functions ('use server' у фреймворках) у тестах компонентів підміняють через параметри чи vi.mock модуля - сама серверна логіка тестується окремо, на сервері. Повний цикл форма → сервер → відповідь краще перевіряти e2e-тестом.
useFormStatus у дочірній кнопці перевіряється так само - через її вигляд (disabled, текст) під час очікування.
jsdom - імітація DOM у Node.js. Він швидкий і достатній для більшості тестів логіки й розмітки, але це не браузер:
- немає розкладки: розміри, позиції, прокрутка - нулі;
- немає реального CSS:
display: noneз класу, медіазапити,:hoverне працюють; - відсутні чи спрощені
IntersectionObserver,ResizeObserver, Canvas, Web Animations,matchMedia, буфер обміну, справжні фокус і клавіатурна навігація; - події генеруються JavaScript-ом, а не браузером.
Тести компонентів, що залежать від цього (віртуалізований список, перетягування, модальне вікно з фокусом, адаптивна верстка, анімації), або потребують купи моків, або перевіряють не те.
Vitest Browser Mode запускає ті самі тести в справжньому браузері (Chromium, Firefox, WebKit через Playwright):
// vitest.config.ts
import { playwright } from '@vitest/browser-playwright';
export default defineConfig({
test: {
browser: {
enabled: true,
provider: playwright(),
instances: [{ browser: 'chromium' }],
},
},
});
import { render } from 'vitest-browser-react';
import { page } from 'vitest/browser';
test('меню відкривається з клавіатури', async () => {
render(<Menu />);
await page.getByRole('button', { name: 'Меню' }).click();
await expect.element(page.getByRole('menu')).toBeVisible();
});
Події йдуть через протокол браузера (як від реального користувача), toBeVisible враховує справжній CSS, а expect.element автоматично чекає на виконання умови.
E2E-тести (Playwright) - окремий рівень: справжній застосунок з бекендом, перехід між сторінками, автентифікація, повний сценарій «знайти вакансію - відгукнутися». Повільніші й дорожчі в підтримці, але єдині перевіряють інтеграцію фронтенду, API й бази.
Як розподіляти:
- jsdom (Testing Library) - більшість тестів компонентів: логіка, умовний рендер, форми, обробка відповідей API. Швидко;
- Browser Mode - компоненти, що залежать від розкладки, CSS, фокусу, браузерних API;
- E2E - кілька критичних сценаріїв: вхід, оплата, основний шлях користувача.
Ціна браузерних тестів: запуск браузера, повільніше виконання, потреба в браузерах на CI (npx playwright install). Тому їх не варто робити режимом за замовчуванням для всього.
У Laravel-проєктах для e2e є ще Pest з браузерними тестами (на Playwright) - сценарії пишуться на PHP і мають доступ до фабрик і бази.
Тести мають давати впевненість і не заважати змінювати код. Тести, що ламаються від кожного рефакторингу, але пропускають справжні помилки, - гірші за їх відсутність: команда звикає їх «оновлювати не дивлячись».
Чого не тестувати - деталі реалізації:
- внутрішній стан (
useState), назви змінних, приватні функції компонента; - порядок і кількість викликів хуків, кількість рендерів (крім спеціальних тестів продуктивності);
- структуру розмітки й класи CSS -
container.querySelector('.card > div:nth-child(2)')ламається від будь-якої зміни верстки; - поведінку бібліотек - що React рендерить, що роутер переходить, що TanStack Query кешує. Це вже протестовано;
- дрібні компоненти без логіки (обгортка над
<button>з класами) - їх перевіряють тести компонентів, що їх використовують.
Що тестувати: те, що бачить і робить користувач, і контракт компонента - як він реагує на props і дії, що відправляє на сервер, що показує при помилці, порожньому списку, завантаженні.
Знімкові тести (toMatchSnapshot) зберігають весь відрендерений HTML і порівнюють з ним при наступних запусках.
Чим вони небезпечні:
- ламаються від будь-якої зміни - нового класу, перенесеного атрибута, зміни тексту. Більшість падінь - не баги;
- «оновити всі знімки» (
-u) стає рутиною, і справжня регресія проходить разом з іншими змінами; - великі знімки ніхто не читає на рев'ю - дифф на 300 рядків розмітки;
- знімок фіксує поточну поведінку, а не правильну - якщо баг був при створенні знімка, тест його захищає.
Коли знімки доречні:
- маленькі й сфокусовані -
toMatchInlineSnapshotдля результату функції форматування, повідомлення про помилку, згенерованого фрагмента; - серіалізовані дані, а не розмітка: структура запиту до API, конфігурація.
Замість знімка сторінки - кілька явних перевірок того, що важливо:
expect(screen.getByRole('heading', { level: 1 })).toHaveTextContent('Вакансії');
expect(screen.getAllByRole('article')).toHaveLength(3);
expect(screen.getByRole('link', { name: 'Наступна сторінка' })).toHaveAttribute('href', '/jobs?page=2');
Візуальні регресійні тести (Playwright toHaveScreenshot, Chromatic) - окремий інструмент для перевірки вигляду, і тоді вони порівнюють зображення, а не HTML.
Покриття коду - корисний сигнал, а не мета: 100% покриття тестами деталей реалізації дає менше впевненості, ніж 70% поведінкових тестів.
Докладніше в документації: Testing Library: керівні принципи
Питання з реальних технічних співбесід - 100 питань у 8 темах, розібраних із відповідями. Нижче - розбивка за рівнями та темами, якщо хочете звузити підготовку.
- Теми
- Хуки 15 Рендеринг 15 Стан і дані 12 Форми й Actions 12 Next.js, Inertia й маршрутизація 12 TypeScript та інструменти 12 Продуктивність 12 Тестування 10
Готуєтесь до співбесіди не просто так: зараз на сайті 145 відкритих вакансій Laravel і PHP. Переглянути вакансії