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

Питання на співбесіді з React

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

100 питань

Класичний спосіб - onSubmit:

function SearchForm() {
  function handleSubmit(e) {
    e.preventDefault();                       // інакше браузер перезавантажить сторінку
    const data = new FormData(e.currentTarget);
    search(data.get('q'));
  }

  return (
    <form onSubmit={handleSubmit}>
      <input name="q" />
      <button type="submit">Шукати</button>
    </form>
  );
}

React 19 - функція в пропі action:

function SearchForm() {
  async function searchAction(formData) {
    await search(formData.get('q'));
  }

  return (
    <form action={searchAction}>
      <input name="q" />
      <button type="submit">Шукати</button>
    </form>
  );
}

Чим action відрізняється:

  • preventDefault() не потрібен - React сам перехоплює відправку;
  • функція отримує FormData одразу аргументом;
  • виконується всередині Transition - на ній працюють useFormStatus (стан відправки для кнопки), useActionState (результат дії) і useOptimistic;
  • після успішного виконання неконтрольовані поля форми скидаються автоматично. Якщо поле має лишитися заповненим (пошук), значення треба зберігати в стані чи повертати з дії;
  • метод HTTP завжди POST, незалежно від атрибута method;
  • різні кнопки можуть викликати різні дії через formAction:
<button type="submit">Опублікувати</button>
<button formAction={saveDraft}>Зберегти чернетку</button>

Коли все ж onSubmit: складна клієнтська валідація перед відправкою, бібліотеки форм зі своїм handleSubmit (React Hook Form), потреба повністю контролювати процес.

Пастка для обох способів: кнопка без type усередині форми - це type="submit". Кнопка «Скасувати» чи «Додати рядок» без type="button" відправить форму.

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

Поле не реагує на введення. Причина - value без onChange:

<input value={title} />   // поле лише для читання

Контрольоване поле завжди показує те, що передано у value. Користувач натискає клавішу, браузер змінює поле, React повертає старе значення. У консолі - попередження: «You provided a value prop to a form field without an onChange handler».

Варіанти виправлення:

  • потрібне лише початкове значення - defaultValue={title};
  • поле має керуватися станом - додати onChange={(e) => setTitle(e.target.value)};
  • поле справді лише для читання - явно readOnly.

Курсор стрибає в кінець чи на початок. Типові причини:

1. Асинхронне оновлення стану:

onChange={async (e) => {
  await validate(e.target.value);
  setTitle(e.target.value);   // запізно - поле вже перемальовано зі старим значенням
}}

Стан контрольованого поля треба оновлювати синхронно в обробнику, а асинхронну перевірку робити окремо.

2. Поле перестворюється на кожен рендер:

  • компонент поля оголошено всередині іншого компонента - на кожен рендер це новий тип компонента, React знищує старий <input> і створює новий;
  • змінюється key поля (наприклад, key={Math.random()}).
function Form() {
  // помилка: новий компонент на кожен рендер
  const Field = (props) => <input {...props} />;
  return <Field value={title} onChange={...} />;
}

Компоненти оголошують на верхньому рівні модуля.

3. Перетворення значення на ходу (setTitle(e.target.value.toUpperCase())) іноді збиває позицію курсора в деяких браузерах. Для форматування (телефон, сума) краще форматувати при виході з поля чи спеціальні бібліотеки масок.

value={null} чи undefined робить поле неконтрольованим - а коли значення з'явиться, React попередить про перемикання режиму. Початкове значення - ''.

Докладніше в документації: input: розв'язання проблем

Доступна форма - та, якою можна користуватися з клавіатури й зчитувачем екрана. Основа - правильні зв'язки між полями, мітками й повідомленнями.

Мітка для кожного поля. placeholder мітку не замінює: він зникає при введенні й погано читається.

<label htmlFor="email">Електронна адреса</label>
<input id="email" name="email" type="email" />

У JSX - htmlFor, а не for.

Унікальні id - через useId. Жорсткий id="email" ламається, якщо компонент поля використано на сторінці двічі. useId генерує стабільний унікальний id, однаковий на сервері й клієнті (без розбіжностей гідратації):

function TextField({ label, error, ...props }) {
  const id = useId();
  const errorId = `${id}-error`;

  return (
    <div>
      <label htmlFor={id}>{label}</label>
      <input
        id={id}
        aria-invalid={error ? true : undefined}
        aria-describedby={error ? errorId : undefined}
        {...props}
      />
      {error && <p id={errorId} role="alert">{error}</p>}
    </div>
  );
}
  • aria-invalid - зчитувач оголосить, що поле з помилкою;
  • aria-describedby - текст помилки прочитається разом з полем;
  • role="alert" - повідомлення оголошується одразу при появі.

useId - не для ключів у списках: ключі мають походити з даних.

Інші правила:

  • групи радіокнопок і чекбоксів - у <fieldset> з <legend>;
  • фокус на першій помилці після невдалої відправки (React Hook Form робить це сам - shouldFocusError);
  • не вимикати кнопку відправки «доки форма невалідна» - користувач не розуміє, що не так. Краще дозволити відправку й показати помилки;
  • правильні типи полів (type="email", inputMode="numeric", autoComplete="email") - правильна клавіатура на телефоні й автозаповнення;
  • стан відправки оголошувати текстом («Зберігаємо...»), а не лише спінером.

Перевірка: пройти форму лише клавіатурою (Tab, Enter, пробіл) і з увімкненим VoiceOver/NVDA.

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

Є три поширені архітектури, і вони відрізняються тим, хто відповідає за маршрутизацію й дані.

1. Окрема SPA + API Laravel (Vite + React Router, Laravel лише як JSON API, автентифікація через Sanctum):

  • фронтенд і бекенд незалежні: окремі деплої, можна мати кілька клієнтів (веб, мобільний застосунок);
  • треба самому будувати API, валідацію відповідей, обробку помилок, стан завантаження, маршрутизацію й автентифікацію (CORS, CSRF-cookie);
  • без SSR сторінки погано індексуються - для публічного контенту це мінус.

2. Inertia.js (React-сторінки, але маршрути й контролери - Laravel):

return Inertia::render('Orders/Show', ['order' => $order->only('id', 'total', 'status')]);
export default function Show({ order }) { /* ... */ }
  • немає окремого API: контролер передає дані як props сторінки, валідація, авторизація, редиректи, сесії - звичайний Laravel;
  • переходи без перезавантаження, як у SPA;
  • SSR можливий (окремий Node-процес), але не обов'язковий;
  • один застосунок - простіше для невеликої команди. Офіційний стартовий набір Laravel для React побудований саме так.

3. Next.js + Laravel як API:

  • React Server Components, потоковий рендер, кешування на рівні фреймворка, SSR/SSG з коробки - найкраще для SEO й публічних сайтів з великим трафіком;
  • два бекенди: Node-сервер Next.js і Laravel. Автентифікація, кешування й деплой стають складнішими;
  • частина логіки (серверні дії, маршрути) переїжджає з Laravel у Next.js - треба вирішити, де межа.

Як обирати:

Ситуація Вибір
адмінка, кабінет, внутрішній інструмент на Laravel Inertia
кілька клієнтів одного API (веб + мобільний) SPA + API
публічний контент-сайт з вимогами до SEO й швидкості Next.js (або Inertia з SSR)
команда з окремими фронтенд- і бекенд-розробниками SPA чи Next.js + API

Головне питання: чи потрібен окремий API як продукт. Якщо ні - Inertia дає SPA-досвід без витрат на API.

Докладніше в документації: Inertia: як це працює

React Router має три режими, і кожен наступний додає можливості поверх попереднього.

1. Декларативний - маршрути описано в JSX, лише зіставлення URL з компонентами й навігація:

import { BrowserRouter, Routes, Route, Link } from 'react-router';

<BrowserRouter>
  <Routes>
    <Route path="/" element={<Home />} />
    <Route path="/posts/:id" element={<Post />} />
  </Routes>
</BrowserRouter>

Хуки useParams, useNavigate, useLocation, компонент <Link>. Дані завантажує сам компонент.

2. Режим даних - маршрути описано об'єктами поза рендером, і до них додаються завантажувачі даних і дії:

const router = createBrowserRouter([
  { path: '/posts/:id', Component: Post, loader: ({ params }) => getPost(params.id) },
]);

<RouterProvider router={router} />

Дані завантажуються до рендеру сторінки, паралельно для вкладених маршрутів, - без «водоспаду» запитів у useEffect. Дії (action) обробляють форми, після чого дані сторінки перевантажуються автоматично.

3. Фреймворковий - режим даних плюс плагін Vite: типобезпечні модулі маршрутів, автоматичне розділення коду, SSR, статичний рендер чи SPA на вибір. По суті це наступник Remix.

Як обирати: документація радить фреймворковий режим тим, хто не має сильної думки, - він дає найбільше. Декларативний - для простих застосунків або поступового переходу; режим даних - коли потрібні завантажувачі, але власна збірка.

React Router 8 (червень 2026) - перший реліз за щорічним графіком мажорних версій:

  • пакет react-router-dom видалено - усі імпорти з react-router (і react-router/dom);
  • мінімальні версії: React 19.2.7+, Node 22.22+, Vite 7+; пакети тепер лише ESM;
  • поведінка прапорців future.v8_* стала типовою: middleware маршрутів увімкнено завжди, модулі маршрутів розділяються на частини за замовчуванням;
  • у meta-функціях замість застарілого data - loaderData.

Якщо у v7 всі прапорці майбутньої версії вже були увімкнені, перехід на v8 мінімальний.

З Laravel React Router доречний в окремій SPA. В Inertia маршрутизація належить Laravel, і React Router не потрібен.

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

У App Router маршрути визначає структура папок у app/, а спеціальні файли всередині папки задають роль.

app/
├── layout.tsx          # кореневий макет (обов'язковий, містить <html> і <body>)
├── page.tsx            # /
├── blog/
│   ├── layout.tsx      # макет для всіх сторінок /blog/*
│   ├── page.tsx        # /blog
│   └── [slug]/
│       ├── page.tsx    # /blog/:slug
│       ├── loading.tsx # стан завантаження
│       └── error.tsx   # межа помилок

page.tsx - вміст маршруту. Без нього папка не стає доступним URL.

layout.tsx - спільна обгортка для сторінки й усіх вкладених маршрутів:

export default function BlogLayout({ children }: { children: React.ReactNode }) {
  return (
    <section>
      <BlogSidebar />
      {children}
    </section>
  );
}

Ключова властивість макетів: при переході між сторінками всередині макета він не перемонтовується - зберігає стан (відкрита бічна панель, введений пошук) і не рендериться заново. Перемальовується лише змінена частина.

Динамічні сегменти: [slug] - параметр; params у сторінці - Promise, його треба дочекатися:

export default async function Post({ params }: { params: Promise<{ slug: string }> }) {
  const { slug } = await params;
  const post = await getPost(slug);
  return <article>{post.title}</article>;
}

Інші файли:

  • loading.tsx - автоматичний <Suspense>: запасний вигляд, поки сторінка завантажує дані;
  • error.tsx - межа помилок сегмента (має бути клієнтським компонентом);
  • not-found.tsx - для notFound();
  • route.ts - обробник HTTP-запитів (API) замість сторінки.

Групи маршрутів (marketing) - папки в дужках не потрапляють в URL і дають змогу мати різні макети для частин сайту.

За замовчуванням усі ці компоненти - серверні: можна звертатися до бази чи API прямо в компоненті, а 'use client' додається лише там, де потрібна інтерактивність.

Докладніше в документації: Next.js: макети й сторінки

Next.js читає .env-файли й робить змінні доступними через process.env. Але де саме вони доступні, залежить від префікса.

Без префікса - лише на сервері:

DATABASE_URL=postgres://...
STRIPE_SECRET_KEY=sk_live_...

Доступні в серверних компонентах, обробниках маршрутів, серверних функціях. У браузері process.env.STRIPE_SECRET_KEY - undefined.

З префіксом NEXT_PUBLIC_ - у браузері теж:

NEXT_PUBLIC_ANALYTICS_ID=G-XXXX
NEXT_PUBLIC_API_URL=https://api.example.com
setupAnalytics(process.env.NEXT_PUBLIC_ANALYTICS_ID);

Як це працює: під час next build Next.js підставляє значення прямо в код JavaScript, що йде в браузер. Тобто:

  • значення публічне - будь-хто побачить його в коді сторінки. Сюди можна класти лише те, що й так не секретно;
  • значення фіксується на момент збирання. Змінити NEXT_PUBLIC_API_URL на сервері після збірки - нічого не змінить у вже зібраних файлах. Один Docker-образ для staging і production з різними NEXT_PUBLIC_* не працюватиме як очікується;
  • динамічне звернення не підставляється: process.env[name] чи деструктуризація const { NEXT_PUBLIC_X } = process.env дадуть undefined у браузері.

Як передати конфігурацію під час виконання, якщо образ один на всі середовища:

  • читати змінні без префікса в серверному компоненті й передавати потрібні значення в клієнтські компоненти як props;
  • або ендпойнт конфігурації, з якого клієнт отримує налаштування.

Порядок файлів: .env.local (не в Git, перекриває все), .env.development / .env.production, .env. Змінні оточення процесу мають пріоритет над файлами.

Пастки безпеки:

  • випадково додати NEXT_PUBLIC_ до секрету - класичний витік ключа API;
  • серверний модуль з секретами, імпортований у клієнтський компонент, потрапляє в бандл - захист: пакет server-only у таких модулях дає помилку збірки при імпорті з клієнта.

Аналог у Vite (Laravel-проєкти) - префікс VITE_ і import.meta.env, з тими самими правилами: усе з префіксом публічне й фіксується при збиранні.

Докладніше в документації: Next.js: змінні оточення

Props компонента типізують звичайним типом чи інтерфейсом у параметрі функції:

type ButtonProps = {
  label: string;
  variant?: 'primary' | 'secondary';   // необов'язковий
  onClick: () => void;
};

function Button({ label, variant = 'primary', onClick }: ButtonProps) {
  return <button className={variant} onClick={onClick}>{label}</button>;
}

Значення за замовчуванням задають деструктуризацією - окремий defaultProps для функціональних компонентів у React 19 більше не підтримується.

children оголошують явно - як і будь-який інший prop:

import type { ReactNode } from 'react';

type CardProps = {
  title: string;
  children: ReactNode;
};

ReactNode чи ReactElement:

  • ReactNode - усе, що React уміє відрендерити: JSX, рядки, числа, bigint, null, undefined, boolean, масиви цього, а також проміси (для use). Правильний тип для children у більшості випадків;
  • ReactElement - лише результат JSX (<div />, <Card />). Рядок чи null туди не передати. Доречний, коли компонент справді приймає рівно один елемент (наприклад, щоб клонувати його чи додати props).
<Card title="Профіль">Текст</Card>          // ReactNode - ок
<Tooltip content="Підказка"><button /></Tooltip>   // children: ReactElement

React.JSX.Element - тип, який повертає JSX-вираз. У React 19 глобального простору імен JSX більше немає: пишуть React.JSX.Element або імпортують JSX з react. Тип повернення компонента зазвичай не вказують - TypeScript виведе його сам.

React.FC у сучасному коді вживають рідко: він не додає користі порівняно з типізованим параметром, а історично неявно додавав children. Звичайна функція з типізованими props - стандарт.

Пастка: children: JSX.Element забороняє передати текст чи кілька елементів - помилка компіляції там, де її не очікують.

Докладніше в документації: TypeScript у React: типізація children

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

Інлайн-обробник типізувати не треба - TypeScript виведе тип сам:

<input onChange={(event) => setName(event.target.value)} />
// event: React.ChangeEvent<HTMLInputElement>

Окрема функція потребує явного типу:

import type { ChangeEvent, FormEvent, MouseEvent } from 'react';

function handleChange(event: ChangeEvent<HTMLInputElement>) {
  setEmail(event.target.value);
}

function handleSubmit(event: FormEvent<HTMLFormElement>) {
  event.preventDefault();
}

function handleClick(event: MouseEvent<HTMLButtonElement>) {
  console.log(event.clientX);
}

Найчастіші типи: ChangeEvent, FormEvent, MouseEvent, KeyboardEvent, FocusEvent, DragEvent, ClipboardEvent.

Тип самого обробника - зручно для props:

type SearchProps = {
  onSearch: (query: string) => void;                       // власний колбек
  onKeyDown?: React.KeyboardEventHandler<HTMLInputElement>; // обробник DOM-події
};

KeyboardEventHandler<T> - це (event: KeyboardEvent<T>) => void.

Як дізнатися правильний тип: навести курсор на onChange в JSX - редактор покаже очікувану сигнатуру.

target чи currentTarget:

  • currentTarget - елемент, на якому висить обробник, і його тип відомий (HTMLButtonElement);
  • target - елемент, де виникла подія (може бути вкладений <span> усередині кнопки), тому для MouseEvent він має загальний тип EventTarget.

Для полів введення event.target.value працює, бо в ChangeEvent<HTMLInputElement> тип target уточнено.

Пастки:

  • власні колбеки не варто типізувати як події DOM: onSelect: (event) => ... з передачею всього об'єкта події змушує батька знати про DOM. Краще передавати значення: onSelect(item.id);
  • any для події вимикає перевірку доступу до полів - друкарська помилка event.target.vaule пройде непоміченою.

Докладніше в документації: TypeScript у React: події DOM

TypeScript виводить тип стану з початкового значення, і найчастіше явний тип не потрібен:

const [count, setCount] = useState(0);          // number
const [name, setName] = useState('');           // string
const [open, setOpen] = useState(false);        // boolean

Коли виведення не спрацьовує:

1. Початкове null - значення з'явиться пізніше:

type User = { id: number; name: string };

const [user, setUser] = useState<User | null>(null);

if (user) {
  user.name;   // після перевірки - User
}

Без явного типу useState(null) дасть тип null, і setUser(data) не скомпілюється.

2. Порожній масив:

const [items, setItems] = useState<Product[]>([]);

Без параметра useState([]) дає never[] - у такий масив нічого не додати.

3. Обмежений набір значень (union):

type Status = 'idle' | 'loading' | 'success' | 'error';
const [status, setStatus] = useState<Status>('idle');

Без явного типу стан стане string, і setStatus('sucess') з помилкою пройде.

Стани, що залежать один від одного, краще поєднати в один з дискримінованим union - тоді неможливі комбінації не скомпілюються:

type RequestState =
  | { status: 'loading' }
  | { status: 'success'; data: User[] }
  | { status: 'error'; error: string };

const [state, setState] = useState<RequestState>({ status: 'loading' });

if (state.status === 'success') {
  state.data;   // доступно лише тут
}

Окремі isLoading, error, data дозволяють стан «завантажується, але з помилкою й даними», а union - ні.

Функція оновлення типізується автоматично: setCount((c) => c + 1) знає, що c - число.

Лінива ініціалізація теж виводить тип з результату: useState(() => readFromStorage()).

Пастка: useState<User>({} as User) - «обіцянка» компілятору, що порожній об'єкт - повноцінний User. Помилки звернення до відсутніх полів з'являться під час виконання, а не компіляції. Чесніше User | null.

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

<StrictMode> - обгортка, яка в режимі розробки вмикає додаткові перевірки. У продакшен-збірці вона нічого не робить і нічого не коштує.

createRoot(document.getElementById('root')!).render(
  <StrictMode>
    <App />
  </StrictMode>,
);

Шаблони Vite і більшості фреймворків вмикають її за замовчуванням.

Що вона робить:

1. Рендерить компоненти двічі. Функція компонента, ініціалізатори useState, функції useMemo та оновлювачі стану викликаються по два рази. Якщо результат різний - компонент нечистий: змінює щось зовнішнє під час рендеру.

function List({ items }) {
  items.push({ id: 'extra' });   // мутація props - у StrictMode побачите два 'extra'
  return items.map(...);
}

2. Запускає ефекти двічі при монтуванні: setup → cleanup → setup. Так React перевіряє, що ефект правильно прибирає за собою:

useEffect(() => {
  const connection = createConnection(roomId);
  connection.connect();
  return () => connection.disconnect();   // без цього - два з'єднання
}, [roomId]);

Якщо ефект ламається від повторного запуску, він зламається й у реальному житті - при поверненні на сторінку, швидкому оновленні (Fast Refresh) чи в майбутніх можливостях React, що зберігають стан при прихованні компонентів.

3. Перевіряє колбеки ref так само: прив'язка - відв'язка - прив'язка.

4. Попереджає про застарілі API.

Типові «симптоми» в розробці:

  • console.log у компоненті друкує двічі (React DevTools можуть приглушувати друге виведення);
  • запит у useEffect йде двічі - це нормально в розробці. Якщо він не скасовується й дає дублікати даних (два однакові записи через POST в ефекті), проблема в коді, а не в StrictMode;
  • лічильник, збільшений в ефекті без очищення, показує 2.

Чого НЕ робити: вимикати StrictMode, щоб «прибрати подвійний запит». Правильне рішення - очищення в ефекті, скасування запиту (AbortController) або завантаження даних засобами фреймворку чи бібліотекою на кшталт TanStack Query.

Додатково: React Compiler і правила ESLint eslint-plugin-react-hooks знаходять ті самі порушення чистоти ще до запуску.

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

Коли змінюється стан компонента, React перерендерює його і всіх нащадків. Перш ніж обгортати все в memo, варто змінити структуру - часто цього досить.

1. Опустити стан туди, де він потрібен (state colocation):

// було: введення в пошук перерендерює всю сторінку
function Page() {
  const [query, setQuery] = useState('');
  return (
    <>
      <input value={query} onChange={(e) => setQuery(e.target.value)} />
      <Results query={query} />
      <HeavySidebar />      {/* рендериться на кожну літеру */}
    </>
  );
}

// стало: стан живе в компоненті, якому він потрібен
function Search() {
  const [query, setQuery] = useState('');
  return (
    <>
      <input value={query} onChange={(e) => setQuery(e.target.value)} />
      <Results query={query} />
    </>
  );
}

function Page() {
  return (
    <>
      <Search />
      <HeavySidebar />      {/* більше не залежить від введення */}
    </>
  );
}

2. Передати важкий вміст як children:

function Collapsible({ children }) {
  const [open, setOpen] = useState(false);
  return (
    <section>
      <button onClick={() => setOpen(!open)}>Розгорнути</button>
      {open && children}
    </section>
  );
}

<Collapsible>
  <HeavyChart />
</Collapsible>

Коли змінюється open, React перерендерює Collapsible, але children - це JSX, створений батьком, і він не змінився. HeavyChart не рендериться заново.

Інші принципи з документації React:

  • не оновлювати стан в ефектах без потреби - ланцюжки «ефект встановлює стан - рендер - ефект» множать рендери;
  • обчислювати похідні значення під час рендеру, а не зберігати копію в стані;
  • прибирати зайві залежності ефектів - функції й об'єкти, створені в тілі компонента, змінюються на кожен рендер.

Рендер - не обов'язково проблема. Рендер - це виклик функції й порівняння результату; DOM змінюється лише там, де результат відрізняється. Оптимізувати варто тоді, коли профілювання показує помітні затримки, а не «про всяк випадок».

React Compiler автоматично додає мемоізацію, але структурні рішення лишаються корисними: чим менше компонентів залежить від стану, що часто змінюється, тим менше роботи взагалі.

Докладніше в документації: memo: чи варто додавати memo всюди

Початкове значення useState використовується лише при першому рендері. Але вираз, переданий аргументом, обчислюється на кожному рендері - бо це звичайний виклик функції компонента.

function Editor() {
  // parseDraft викликається на КОЖНОМУ рендері, хоча результат потрібен один раз
  const [draft, setDraft] = useState(parseDraft(localStorage.getItem('draft')));
  // ...
}

Якщо обчислення дороге (розбір великого JSON, читання localStorage, побудова структури з тисяч елементів), компонент сповільнюється на кожне введення в поле.

Лінива ініціалізація - передати функцію, а не її результат:

const [draft, setDraft] = useState(() => parseDraft(localStorage.getItem('draft')));

React викличе функцію лише при першому рендері. Зверніть увагу на різницю:

useState(createInitialTodos());   // викликається щоразу
useState(createInitialTodos);     // передано саму функцію - лише раз
useState(() => createInitialTodos(userId));   // з аргументом - через обгортку

Те саме для useReducer - третій аргумент-ініціалізатор:

const [state, dispatch] = useReducer(reducer, userId, createInitialState);

Для useRef вбудованої лінивої ініціалізації немає - роблять вручну:

const playerRef = useRef<VideoPlayer | null>(null);
if (playerRef.current === null) {
  playerRef.current = new VideoPlayer();   // створюється один раз
}

Що варто знати:

  • у StrictMode функція-ініціалізатор у режимі розробки викликається двічі - вона має бути чистою (без запитів, підписок, побічних ефектів);
  • для дешевих значень (useState(0), useState([]), useState(props.initial)) лінива ініціалізація не потрібна - виграшу немає;
  • ініціалізація з props відбувається один раз: useState(props.value) не оновиться, коли props.value зміниться. Якщо значення має стежити за props, стан не потрібен - обчислюйте під час рендеру, а для «скидання» стану використайте key.

Докладніше в документації: useState: уникнення повторного створення початкового стану

За замовчуванням збирач кладе весь код застосунку в один файл: користувач, що відкрив головну сторінку, завантажує й код адмінки, редактора, графіків. Розділення коду (code splitting) виносить частини в окремі файли, що завантажуються за потреби.

lazy - компонент, код якого завантажується при першому рендері:

import { lazy, Suspense } from 'react';

const MarkdownEditor = lazy(() => import('./MarkdownEditor'));

function PostForm() {
  const [editing, setEditing] = useState(false);
  return (
    <>
      <button onClick={() => setEditing(true)}>Редагувати</button>
      {editing && (
        <Suspense fallback={<p>Завантаження редактора...</p>}>
          <MarkdownEditor />
        </Suspense>
      )}
    </>
  );
}

Збирач (Vite) бачить динамічний import() і виносить MarkdownEditor з усіма залежностями в окремий файл.

Suspense показує fallback, поки код завантажується. Одна межа Suspense може охоплювати кілька лінивих компонентів.

Правила:

  • lazy - на верхньому рівні модуля, не всередині компонента. Інакше на кожен рендер створюється новий компонент - стан губиться, і код завантажується знову;
  • модуль має експортувати компонент за замовчуванням (export default). Для іменованого експорту - проміжний модуль чи import('./x').then((m) => ({ default: m.Editor }));
  • помилка завантаження (мережа, файл зник після деплою) кидається як виняток - потрібна межа помилок (Error Boundary), щоб показати повідомлення й кнопку «Оновити».

Що варто виносити:

  • сторінки й маршрути - найприродніша межа; маршрутизатори (React Router, TanStack Router) мають для цього вбудовану підтримку;
  • важкі бібліотеки - редактори, графіки, карти, PDF;
  • рідко потрібне - модальні вікна налаштувань, експорт.

Не варто дробити кожен компонент: багато дрібних файлів - багато запитів і затримки «водоспадом». Межі - за сценаріями використання.

Попереднє завантаження: щоб користувач не чекав після кліку, імпорт можна запустити заздалегідь - при наведенні на кнопку: onMouseEnter={() => import('./MarkdownEditor')}. Повторний import() використає вже завантажений модуль.

Inertia розділяє код за сторінками автоматично, якщо в resolve використовується import.meta.glob без eager: true.

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

React Developer Tools - розширення браузера (Chrome, Firefox, Edge) з двома вкладками: Components і Profiler.

Components - дерево компонентів зі станом, props і хуками кожного. Корисне для продуктивності тим, що:

  • показує, які props отримав компонент - видно, коли передається новий об'єкт чи функція на кожен рендер;
  • у налаштуваннях можна ввімкнути «Highlight updates when components render» - компоненти, що перерендерились, підсвічуються на сторінці. Одразу видно, що введення в поле перемальовує всю сторінку.

Profiler - запис сесії і аналіз:

  1. натиснути «Record», виконати повільну дію (ввести текст, відкрити список), зупинити запис;
  2. для кожного коміту (застосування змін у DOM) - діаграма flamegraph: які компоненти рендерились і скільки часу зайняли;
  3. Ranked - компоненти, відсортовані за часом рендеру;
  4. клік на компонент - скільки разів і чому він рендерився.

«Why did this render?» - у налаштуваннях Profiler увімкнути «Record why each component rendered»: DevTools покаже причину - змінився стан, змінились певні props, змінився контекст, перерендерився батько.

На що дивитися:

  • компоненти, що рендеряться, хоча їх дані не змінились - кандидати на опускання стану, розділення контексту чи мемоізацію;
  • один повільний компонент з великим власним часом - важке обчислення в рендері;
  • багато комітів на одну дію - ланцюжки оновлень стану в ефектах.

Значок React Compiler: компоненти, оптимізовані компілятором, позначені в DevTools міткою «Memo ✨» - видно, де компілятор спрацював, а де ні.

Важливо:

  • вимірюйте production-збірку: режим розробки повільніший (додаткові перевірки, StrictMode) - час у ньому завищений. Для профілювання production потрібна спеціальна збірка з увімкненим профілюванням;
  • вкладка Performance браузера доповнює React DevTools: показує, що поза React (мережа, розкладка, стилі) займає час. У нових версіях React додає в неї власні доріжки (React Performance tracks) з етапами рендеру;
  • спершу виміряти, потім оптимізувати - інтуїція щодо того, що повільне, часто помиляється.

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

Питання з реальних технічних співбесід - 100 питань у 8 темах, розібраних із відповідями. Нижче - розбивка за рівнями та темами, якщо хочете звузити підготовку.

Рівні
Junior 33 Middle 34 Senior 33

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