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

Питання на співбесіді з 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 вже містить це налаштування.

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

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).

Докладніше в документації: eslint-plugin-react-hooks

Увімкнути компілятор на великому існуючому проєкті одним кроком ризиковано: код, що порушує правила 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 попереджає про відомі випадки;
  • ефекти, що залежали від нестабільних залежностей і раніше спрацьовували частіше.

Порядок дій:

  1. увімкнути правила компілятора в eslint-plugin-react-hooks і виправити порушення;
  2. ввімкнути компілятор на частині коду з хорошими тестами;
  3. перевірити в DevTools, які компоненти оптимізовано (мітка «Memo ✨»);
  4. порівняти метрики (INP, час рендеру), розширювати область.

Існуючі useMemo/useCallback не обов'язково прибирати одразу - компілятор їх враховує, а правило preserve-manual-memoization попередить, якщо не зможе зберегти ручну мемоізацію.

Докладніше в документації: React Compiler: директиви

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 - довгі задачі під час гідратації.

Докладніше в документації: hydrateRoot

Повільна сторінка часто повільна не через рендер, а через водоспад завантажень: компонент рендериться - лише тоді дізнається, що потрібен шрифт, скрипт чи дані - запитує - чекає. 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, текст) під час очікування.

Докладніше в документації: useActionState

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 і мають доступ до фабрик і бази.

Докладніше в документації: Vitest Browser Mode

Тести мають давати впевненість і не заважати змінювати код. Тести, що ламаються від кожного рефакторингу, але пропускають справжні помилки, - гірші за їх відсутність: команда звикає їх «оновлювати не дивлячись».

Чого не тестувати - деталі реалізації:

  • внутрішній стан (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 темах, розібраних із відповідями. Нижче - розбивка за рівнями та темами, якщо хочете звузити підготовку.

Рівні
Junior 33 Middle 34 Senior 33

Готуєтесь до співбесіди не просто так: зараз на сайті 145 відкритих вакансій Laravel і PHP. Переглянути вакансії