Питання на співбесіді з 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" відправить форму.
Поле не реагує на введення. Причина - 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 попередить про перемикання режиму. Початкове значення - ''.
Доступна форма - та, якою можна користуватися з клавіатури й зчитувачем екрана. Основа - правильні зв'язки між полями, мітками й повідомленнями.
Мітка для кожного поля. 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.
Є три поширені архітектури, і вони відрізняються тим, хто відповідає за маршрутизацію й дані.
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.
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 не потрібен.
У 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 читає .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, з тими самими правилами: усе з префіксом публічне й фіксується при збиранні.
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 виводить тип стану з початкового значення, і найчастіше явний тип не потрібен:
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.
<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 знаходять ті самі порушення чистоти ще до запуску.
Коли змінюється стан компонента, 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.
React Developer Tools - розширення браузера (Chrome, Firefox, Edge) з двома вкладками: Components і Profiler.
Components - дерево компонентів зі станом, props і хуками кожного. Корисне для продуктивності тим, що:
- показує, які props отримав компонент - видно, коли передається новий об'єкт чи функція на кожен рендер;
- у налаштуваннях можна ввімкнути «Highlight updates when components render» - компоненти, що перерендерились, підсвічуються на сторінці. Одразу видно, що введення в поле перемальовує всю сторінку.
Profiler - запис сесії і аналіз:
- натиснути «Record», виконати повільну дію (ввести текст, відкрити список), зупинити запис;
- для кожного коміту (застосування змін у DOM) - діаграма flamegraph: які компоненти рендерились і скільки часу зайняли;
- Ranked - компоненти, відсортовані за часом рендеру;
- клік на компонент - скільки разів і чому він рендерився.
«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) з етапами рендеру;
- спершу виміряти, потім оптимізувати - інтуїція щодо того, що повільне, часто помиляється.
Питання з реальних технічних співбесід - 100 питань у 8 темах, розібраних із відповідями. Нижче - розбивка за рівнями та темами, якщо хочете звузити підготовку.
- Теми
- Хуки 15 Рендеринг 15 Стан і дані 12 Форми й Actions 12 Next.js, Inertia й маршрутизація 12 TypeScript та інструменти 12 Продуктивність 12 Тестування 10
Готуєтесь до співбесіди не просто так: зараз на сайті 145 відкритих вакансій Laravel і PHP. Переглянути вакансії