Питання на співбесіді з React
Питання з реальних співбесід з відповідями: Laravel і PHP, бази даних, JavaScript і фронтенд, Git, Docker, API, безпека й архітектура. Тими самими темами, що й тести.
100 питань
Стан каталогу (фільтри, пошук, сортування, сторінка) у useState зникає при оновленні сторінки, не працює з кнопкою «Назад» і не передається посиланням. URL - природне місце для такого стану.
Що дає стан в URL:
- посилання відтворює екран: «ось вакансії PHP у Києві, віддалено» - одне посилання;
- «Назад» і «Вперед» повертають попередні фільтри;
- оновлення сторінки нічого не губить;
- серверний рендер і SEO: сервер бачить параметри й рендерить правильний вміст;
- аналітика бачить, як саме користуються фільтрами.
З React Router:
import { useSearchParams } from 'react-router';
function Vacancies() {
const [searchParams, setSearchParams] = useSearchParams();
const city = searchParams.get('city') ?? 'all';
const page = Number(searchParams.get('page') ?? 1);
function changeCity(nextCity) {
setSearchParams((params) => {
params.set('city', nextCity);
params.delete('page'); // новий фільтр - перша сторінка
return params;
});
}
// ...
}
Значення читаються з URL під час рендеру - окремий useState для них не потрібен. URL і є джерелом правди.
Без роутера - URLSearchParams + history.replaceState/pushState і підписка на popstate (через useSyncExternalStore).
Що варто врахувати:
pushчиreplace: зміна фільтра - новий запис історії (користувач повертається «Назад» до попередніх фільтрів); введення в поле пошуку -replace, щоб не засмічувати історію кожною літерою;- debounce для полів введення: не змінювати URL на кожне натискання;
- валідація: параметри з URL - дані від користувача. Невідоме значення сортування чи від'ємна сторінка мають оброблятися без падіння;
- типи: усе в URL - рядки; числа й булеві значення треба перетворювати (бібліотеки на кшталт
nuqsроблять це з типами); - короткі й стабільні назви параметрів - вони стають частиною публічних посилань.
Що НЕ варто тримати в URL: тимчасовий стан інтерфейсу (відкрите меню, наведення), персональні дані, великі обсяги даних.
Для Laravel-бекенду формат на кшталт tags[]=php&tags[]=vue зручний: сервер отримає масив.
Збереження стану між сесіями (тема, згорнута бічна панель, чернетка форми) - типова задача. Найпростіший варіант:
function useLocalStorageState(key, initialValue) {
const [value, setValue] = useState(() => {
try {
const stored = localStorage.getItem(key);
return stored !== null ? JSON.parse(stored) : initialValue;
} catch {
return initialValue;
}
});
useEffect(() => {
try {
localStorage.setItem(key, JSON.stringify(value));
} catch {
// сховище заповнене чи недоступне - стан працює і без збереження
}
}, [key, value]);
return [value, setValue];
}
Лінива ініціалізація (функція в useState) - читання з localStorage лише один раз, а не на кожному рендері.
Пастки:
1. Серверний рендер. На сервері localStorage не існує - код вище впаде з ReferenceError. А якщо перевіряти typeof window, сервер відрендерить початкове значення, клієнт - збережене, і React повідомить про розбіжність гідратації.
Правильно - на першому рендері використовувати те саме значення, що й сервер, і читати сховище після монтування; або useSyncExternalStore з getServerSnapshot, що повертає значення за замовчуванням. Для теми, щоб не було «спалаху», - невеликий скрипт у <head>, що ставить клас до запуску React, або cookie, яку бачить і сервер.
2. Синхронізація між вкладками. Зміни в одній вкладці не видно в іншій без підписки на подію storage. useSyncExternalStore з підпискою на неї вирішує це.
3. Зміна формату даних. Нова версія застосунку очікує інший формат, а в браузерах лежать старі дані. Додавайте версію в ключ (settings:v2) чи валідацію при читанні (Zod) - некоректні дані замінюються значенням за замовчуванням.
4. Винятки. localStorage кидає помилки в приватному режимі, при заповненому сховищі (~5 МБ), при заборонених cookie - доступ обгортається в try/catch.
5. Безпека. Будь-який скрипт на сторінці читає localStorage - туди не кладуть токени автентифікації й персональні дані.
6. Продуктивність. setItem синхронний; запис великого об'єкта на кожне натискання клавіші - помітні затримки. Для чернеток - debounce.
Готові рішення: useLocalStorage з бібліотек хуків, middleware persist у Zustand, redux-persist - вони вже враховують частину цих проблем.
useActionState (React 19) - стан, що оновлюється результатом дії (Action). Зручно для форм: дія відправляє дані, а повернене значення стає новим станом - повідомленням про успіх чи помилками.
import { useActionState } from 'react';
async function subscribe(previousState, formData) {
const response = await fetch('/api/subscribe', {
method: 'POST',
headers: { Accept: 'application/json' },
body: formData,
});
if (response.status === 422) {
const { errors } = await response.json();
return { errors, email: formData.get('email') };
}
return { success: true };
}
function SubscribeForm() {
const [state, formAction, isPending] = useActionState(subscribe, { errors: {} });
if (state.success) return <p>Дякуємо за підписку!</p>;
return (
<form action={formAction}>
<input name="email" type="email" defaultValue={state.email} />
{state.errors?.email && <p role="alert">{state.errors.email[0]}</p>}
<button disabled={isPending}>{isPending ? 'Надсилаємо...' : 'Підписатися'}</button>
</form>
);
}
Як працює:
useActionState(reducerAction, initialState)повертає[state, dispatchAction, isPending];reducerAction(previousState, payload)- як редюсер уuseReducer, але може бути асинхронним і мати побічні ефекти. Для формиpayload- цеFormData;isPending- чи виконується дія;- кілька викликів ставляться в чергу й виконуються послідовно: кожен отримує результат попереднього. Подвійне натискання не дасть двох паралельних запитів у довільному порядку;
dispatchActionтреба викликати з дії: передати в<form action>чи<button formAction>, або обгорнути вstartTransition.
Пастки:
- форма скидається після успішної дії - неконтрольовані поля очищуються. Щоб не губити введене при помилці, повертайте значення в стані (
defaultValue={state.email}, як у прикладі); - кинутий виняток у дії скасовує всі дії в черзі й показує найближчий Error Boundary. Очікувані помилки (валідація) - повертати як стан, а не кидати;
- початковий стан і тип результату мають збігатися - інакше TypeScript скаржиться на невідповідність типів.
Server Functions: з Next.js чи іншим RSC-фреймворком дія може бути серверною функцією ('use server') - тоді форма працює навіть до завантаження JavaScript (третій аргумент permalink).
useFormStatus (з react-dom) повертає стан відправки батьківської форми: pending, data (FormData, що відправляється), method, action.
import { useFormStatus } from 'react-dom';
function SubmitButton({ children }) {
const { pending } = useFormStatus();
return (
<button type="submit" disabled={pending} aria-busy={pending}>
{pending ? 'Зберігаємо...' : children}
</button>
);
}
function ProfileForm() {
return (
<form action={updateProfile}>
<input name="name" />
<SubmitButton>Зберегти</SubmitButton>
</form>
);
}
Кнопку з власним станом можна використовувати в будь-якій формі - без передачі isSubmitting через props.
Чому pending завжди false. Хук бачить лише форму, всередині якої рендериться компонент. Якщо викликати його в тому самому компоненті, що рендерить <form>, батьківської форми для хука немає:
function ProfileForm() {
const { pending } = useFormStatus(); // завжди false: форма нижче, а не вище
return <form action={updateProfile}>...</form>;
}
Тому стан відправки виносять в окремий дочірній компонент (кнопка, індикатор), а в компоненті з формою для цього є isPending з useActionState.
Інші умови роботи:
- форма має відправлятися через дію (
action={функція}). ЗonSubmitчиaction="/url"хук нічого не знає про відправку; - поле
dataдає змогу показати, що саме відправляється («Додаємо "Купити молоко"...»).
Навіщо блокувати кнопку на час відправки: від подвійних відправок (два замовлення, два коментарі). Але це UX-захист - сервер теж має бути готовим до повторів (ключі ідемпотентності, унікальні обмеження).
Доступність: aria-busy і текстова зміна на кнопці, щоб стан відправки був зрозумілий не лише візуально.
Для простих форм вистачає нативних засобів React 19 (action, useActionState). Коли полів багато, є валідація з залежностями між полями, динамічні списки полів і складний UX помилок, - зручніше бібліотека.
React Hook Form керує формою через неконтрольовані поля й refs: введення не викликає рендер усієї форми. Стан помилок, «брудності», відправки - окремо.
Zod описує схему даних, з якої виходить і валідація, і тип TypeScript.
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
const schema = z.object({
email: z.email('Некоректна адреса'),
password: z.string().min(8, 'Щонайменше 8 символів'),
age: z.coerce.number().int().min(18, 'Лише для повнолітніх'),
});
type FormValues = z.infer<typeof schema>;
function SignUpForm() {
const {
register,
handleSubmit,
formState: { errors, isSubmitting },
} = useForm<FormValues>({ resolver: zodResolver(schema) });
const onSubmit = async (values: FormValues) => {
await api.signUp(values);
};
return (
<form onSubmit={handleSubmit(onSubmit)} noValidate>
<input type="email" {...register('email')} aria-invalid={!!errors.email} />
{errors.email && <p role="alert">{errors.email.message}</p>}
<input type="password" {...register('password')} />
{errors.password && <p role="alert">{errors.password.message}</p>}
<input type="number" {...register('age')} />
<button disabled={isSubmitting}>Зареєструватися</button>
</form>
);
}
Що дає поєднання:
- одна схема - і правила, і тип даних (
z.infer); схему можна використати повторно на сервері (Node) чи для перевірки відповіді API; handleSubmitвикликаєonSubmitлише з валідними даними, а при помилках ставить фокус на перше невалідне поле;- режими перевірки:
mode: 'onSubmit'(за замовчуванням),'onBlur','onChange','onTouched'.
Що варто знати:
- у Zod 4 формати рядків стали окремими функціями:
z.email()замість старогоz.string().email()(той лишився, але застарів); - значення полів - рядки: для чисел -
z.coerce.number()чиvalueAsNumberуregister; - клієнтська валідація - лише зручність. Сервер (Laravel Form Request) перевіряє все заново, а його помилки треба показати у формі через
setError; - для сторонніх компонентів (селекти, редактори) -
Controllerз React Hook Form.
Коли API на Laravel не пропускає дані, він відповідає 422 Unprocessable Content з JSON:
{
"message": "The email has already been taken. (and 1 more error)",
"errors": {
"email": ["Ця адреса вже зайнята."],
"items.0.qty": ["Кількість має бути не менше 1."]
}
}
Laravel повертає JSON (а не редирект назад), якщо запит має заголовок Accept: application/json - його обов'язково треба надсилати з fetch.
З React Hook Form - setError:
const { register, handleSubmit, setError, formState: { errors } } = useForm();
async function onSubmit(values) {
const response = await fetch('/api/orders', {
method: 'POST',
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
body: JSON.stringify(values),
});
if (response.status === 422) {
const { errors: serverErrors } = await response.json();
for (const [field, messages] of Object.entries(serverErrors)) {
setError(field, { type: 'server', message: messages[0] }, { shouldFocus: true });
}
return;
}
if (!response.ok) {
setError('root.server', { message: 'Не вдалося зберегти. Спробуйте ще раз.' });
return;
}
}
{errors.root?.server && <p role="alert">{errors.root.server.message}</p>}
Що тут важливо:
- ключі Laravel з крапками (
items.0.qty) збігаються з іменами полів React Hook Form для масивів (register('items.0.qty')) - помилка потрапить точно до потрібного поля; - кілька повідомлень на поле - Laravel повертає масив; зазвичай показують перше;
- помилки, що не стосуються поля (сервер недоступний, 403, 500) - окремо, через
root.*; - серверні помилки скидаються при наступній зміні поля чи відправці - так поводиться React Hook Form для
setError.
Без бібліотеки - те саме зі станом: const [errors, setErrors] = useState({}) і useActionState, що повертає errors з 422-відповіді.
Inertia поводиться інакше: там Laravel відповідає не 422, а редиректом назад з помилками в сесії, і вони приходять у сторінку як проп errors (useForm().errors) - ручна обробка не потрібна.
Інші статуси, які варто обробити: 419 (сесія чи CSRF-токен застаріли - запропонувати оновити сторінку) і 429 (забагато спроб).
У App Router компоненти за замовчуванням серверні. Директива 'use client' на початку файлу позначає межу: цей модуль і все, що він імпортує, потрапляє в клієнтський бандл.
// app/products/[id]/page.tsx - серверний компонент
import AddToCart from './add-to-cart';
export default async function Page({ params }) {
const { id } = await params;
const product = await db.product.find(id); // запит прямо в компоненті
return (
<>
<h1>{product.name}</h1>
<AddToCart productId={product.id} price={product.price} />
</>
);
}
// add-to-cart.tsx
'use client';
export default function AddToCart({ productId, price }) {
const [qty, setQty] = useState(1);
return <button onClick={() => addToCart(productId, qty)}>У кошик - {price * qty} грн</button>;
}
Що можна передати з серверного в клієнтський компонент - лише серіалізовані значення:
- примітиви, звичайні об'єкти й масиви,
Date,Map,Set, типізовані масиви; - Promise (клієнт розгорне через
use()); - React-елементи (JSX), зокрема
children; - серверні функції (
'use server').
Не можна: звичайні функції (обробники подій), екземпляри класів, моделі ORM з методами, символи (крім глобально зареєстрованих).
Важливі наслідки:
- усе передане видно в браузері. Передати в клієнтський компонент повний об'єкт користувача з бази - відправити в HTML і хеш пароля, і службові поля. Передавайте лише потрібні поля;
- межу ставлять якомога нижче - на інтерактивний «листок» (кнопка, форма), а не на всю сторінку. Інакше весь вміст і його залежності йдуть у бандл;
- серверний компонент можна вставити в клієнтський через
children, але не імпортувати в клієнтський модуль напряму - імпорт перетворить його на клієнтський; - контекст і провайдери (
ThemeProvider) - клієнтські компоненти, що обгортаютьchildrenу макеті; - бібліотеки без
'use client', що використовують хуки, треба обгорнути у власний клієнтський модуль.
'use client' - не «рендер лише в браузері». Клієнтські компоненти теж рендеряться на сервері при першому завантаженні (SSR), а потім гідруються. Тому в їхньому рендері не можна звертатися до window без перевірок.
Докладніше в документації: Next.js: серверні й клієнтські компоненти
Модель кешування в Next.js змінювалася між версіями, і це найчастіше джерело плутанини на співбесідах.
Що відбувається з fetch зараз:
- запити не кешуються за замовчуванням (так з Next.js 15; у 13-14 було навпаки) - кожен запит до сторінки отримує свіжі дані;
- однакові
fetchв одному рендері мемоізуються - можна запитувати дані в кожному компоненті, якому вони потрібні, і запит піде один раз. Мемоізація діє лише в межах одного запиту до сервера; - для власних функцій доступу до бази таку мемоізацію дає
React.cache.
Cache Components (Next.js 16) - нова модель, що вмикається в конфігурації:
// next.config.ts
const nextConfig = { cacheComponents: true };
Тоді кешування задається директивою 'use cache' - на рівні функції з даними чи цілого компонента:
import { cacheLife, cacheTag } from 'next/cache';
export async function getProducts(category: string) {
'use cache';
cacheLife('hours');
cacheTag('products');
return db.product.findMany({ where: { category } });
}
- аргументи стають частиною ключа кешу - різні категорії кешуються окремо;
cacheLifeзадає тривалість (документація радить додавати її до кожної директиви);cacheTag+revalidateTag('products')- точкове скидання кешу після зміни даних, наприклад у серверній функції після збереження товару;- кешовані частини стають статичною «оболонкою» сторінки, а некешовані - рендеряться під час запиту всередині
<Suspense>і надходять потоком.
Пастки:
- персональні дані в кеші: функція з
'use cache', що читає дані поточного користувача, спільна для всіх, якщо користувач не є частиною ключа. Значення зcookies()/headers()всередині кешованої області використовувати не можна - їх передають аргументами; - застарілі дані після змін - без
revalidateTag/revalidatePathу місці зміни користувачі бачитимуть старе до кінця терміну; - документація для старої моделі (опції
fetch{ next: { revalidate } },export const revalidate) досі трапляється - вона стосується режиму без Cache Components.
Порівняно з Laravel: це аналог Cache::remember з тегами, але вбудований у рендер сторінок.
Деякі дані потрібні на кожній сторінці: поточний користувач для шапки, назва застосунку, флеш-повідомлення, налаштування. Передавати їх у кожному контролері незручно - для цього є спільні дані (shared data).
На сервері - middleware HandleInertiaRequests, яке створює адаптер Laravel:
class HandleInertiaRequests extends Middleware
{
public function share(Request $request): array
{
return array_merge(parent::share($request), [
'appName' => config('app.name'),
'auth.user' => fn () => $request->user()?->only('id', 'name', 'avatar_url'),
'can' => fn () => [
'manageUsers' => $request->user()?->can('manage-users') ?? false,
],
]);
}
}
На клієнті - usePage():
import { usePage } from '@inertiajs/react';
export default function Header() {
const { auth, appName } = usePage().props;
return <header>{appName} · {auth.user?.name}</header>;
}
Спільні дані зливаються з props сторінки - назви варто групувати (auth.user, can.*), щоб не перетнутися з props конкретної сторінки.
Що видно в браузері - усе. Props сторінки, включно зі спільними, вбудовуються в HTML першої сторінки й приходять у JSON при кожному переході. Будь-хто може відкрити DevTools і прочитати їх. Звідси правила:
- передавати лише потрібні поля:
$user->only('id', 'name'), а не$request->user()цілком (там можуть бути email, телефон, службові прапорці, токени); - не класти секрети - ключі API, внутрішні налаштування;
- прапорці прав (
can.*) - для відображення, а не для захисту. Сховати кнопку «Видалити» можна за прапорцем, але контролер видалення перевіряє права сам.
Продуктивність:
- спільні дані обчислюються й передаються з кожною відповіддю - документація радить використовувати їх ощадливо;
- замикання (
fn () => ...) обчислюються лише тоді, коли prop справді потрібен (наприклад, не при частковому перезавантаженні інших props); Inertia::once()/shareOnce()(Inertia v3) - для даних, що не змінюються між переходами (довідник країн): клієнт отримує їх один раз і пам'ятає.
Флеш-повідомлення в Inertia v3 мають окремий механізм Inertia::flash() - їх не потрібно вручну додавати в спільні дані.
У режимі даних маршрут описується об'єктом, до якого прив'язані завантажувач (loader) для читання й дія (action) для змін.
import { createBrowserRouter, RouterProvider, useLoaderData, Form, useNavigation } from 'react-router';
const router = createBrowserRouter([
{
path: '/projects/:projectId',
Component: Project,
loader: async ({ params }) => api.getProject(params.projectId),
action: async ({ request, params }) => {
const formData = await request.formData();
return api.updateProject(params.projectId, { title: formData.get('title') });
},
},
]);
function Project() {
const project = useLoaderData();
const navigation = useNavigation();
return (
<Form method="post">
<input name="title" defaultValue={project.title} />
<button disabled={navigation.state === 'submitting'}>Зберегти</button>
</Form>
);
}
Завантажувачі:
- виконуються до рендеру сторінки - компонент отримує готові дані, без станів «завантаження» в
useEffect; - для вкладених маршрутів завантажуються паралельно, а не водоспадом батько → дитина;
- помилка в завантажувачі показує
ErrorBoundaryмаршруту.
Дії:
- викликаються через
<Form method="post">,useSubmitабоfetcher; - після дії всі завантажувачі сторінки перевантажуються автоматично - інтерфейс синхронізований з сервером без ручного оновлення стану;
<Form>створює навігацію (новий запис в історії), аuseFetcher- відправляє без навігації: лайки, позначки «прочитано», дії в рядках таблиці.
Стан очікування: useNavigation() (для навігації й форм) і fetcher.state (для фетчерів) - idle, loading, submitting.
У React Router 8 middleware маршрутів увімкнено завжди: спільна логіка (перевірка автентифікації, контекст) виконується до завантажувачів і дій.
Порівняно з Inertia + Laravel: ідея близька - дані «належать» маршруту, а після мутації сторінка оновлюється. Різниця в тому, що в React Router завантажувачі працюють у браузері (і звертаються до API), а в Inertia ту саму роль виконує контролер Laravel на сервері.
Пастка: дія в режимі даних виконується в браузері - це не серверний код. Валідація й авторизація все одно мають бути в API, куди вона звертається.
useRef має два різні призначення, і з TypeScript це видно в типах.
1. Посилання на DOM-елемент:
const inputRef = useRef<HTMLInputElement>(null);
// тип: RefObject<HTMLInputElement | null>
useEffect(() => {
inputRef.current?.focus(); // до монтування - null, тому ?.
}, []);
return <input ref={inputRef} />;
Тип елемента - відповідний клас DOM: HTMLInputElement, HTMLDivElement, HTMLCanvasElement. Якщо вказати не той (HTMLDivElement для <input>), TypeScript повідомить про помилку в атрибуті ref.
2. Змінне значення, що не викликає рендер:
const timerId = useRef<number | null>(null);
const renders = useRef(0); // RefObject<number>, тип виведено
function start() {
timerId.current = window.setInterval(tick, 1000);
}
Що змінилося в типах React 19:
useRefвимагає аргумент.useRef<number>()без початкового значення - помилка компіляції; треба явноuseRef<number | undefined>(undefined);currentзавжди можна змінювати. РанішеuseRef<T>(null)повертавRefObjectзcurrentлише для читання, аuseRef<T>(initial)-MutableRefObject, і плутанина між ними давала незрозумілі помилки. Тепер є одинRefObject<T>зі зміннимcurrent;MutableRefObjectоголошено застарілим.
window.setInterval, а не setInterval: у проєктах, де є і типи DOM, і типи Node.js, глобальний setInterval повертає NodeJS.Timeout, а не число. Явний window. прибирає конфлікт, або тип ref - ReturnType<typeof setInterval>.
Колбек-ref з очищенням (React 19):
<div ref={(node) => {
if (!node) return;
const observer = new ResizeObserver(onResize);
observer.observe(node);
return () => observer.disconnect(); // функція очищення
}} />
У TypeScript колбек-ref тепер не може неявно повертати значення: ref={(node) => (instance = node)} - помилка, бо присвоєння повертає значення, яке React вважав би функцією очищення. Потрібні фігурні дужки.
Правило з документації: не читати й не змінювати ref.current під час рендеру (крім лінивої ініціалізації) - лише в обробниках подій та ефектах.
До React 19 функціональний компонент не отримував ref як prop - для цього обгортали компонент у forwardRef:
const Input = forwardRef<HTMLInputElement, InputProps>(function Input(props, ref) {
return <input ref={ref} {...props} />;
});
У React 19 ref - звичайний prop:
import type { ComponentProps } from 'react';
type InputProps = ComponentProps<'input'> & {
label: string;
};
function Input({ label, ref, ...props }: InputProps) {
return (
<label>
{label}
<input ref={ref} {...props} />
</label>
);
}
const emailRef = useRef<HTMLInputElement>(null);
<Input label="Email" type="email" ref={emailRef} required />;
forwardRef ще працює, але документація позначає його як непотрібний для нового коду, і в майбутніх версіях його оголосять застарілим.
ComponentProps<'input'> - усі атрибути нативного <input>, включно з ref, обробниками подій, aria-* і data-*. Обгортка автоматично приймає все, що приймає нативний елемент, без ручного перелічення.
Варіанти утилітних типів:
ComponentProps<'button'>- атрибути разом зref(у React 19 для нативних елементів);ComponentPropsWithoutRef<'button'>- те саме безref, коли обгортка свідомо не передає ref далі;ComponentProps<typeof Button>- props іншого компонента. Корисно, щоб розширити чужий компонент чи переиспользувати його типи без експорту.
Перевизначення атрибута: якщо власний prop збігається з нативним, але має інший тип, нативний треба прибрати:
type SelectProps = Omit<ComponentProps<'select'>, 'onChange'> & {
onChange: (value: string) => void;
};
Без Omit типи перетнуться, і TypeScript вимагатиме обробник, сумісний з обома сигнатурами одночасно.
Пастки:
- розгорнути
...propsдо свогоclassName- і переданий ззовні клас перезапише внутрішній. Порядок і злиття класів (clsx,tailwind-merge) треба продумати; - бібліотеки, що підтримують React 18, досі використовують
forwardRef, і це нормально - для них потрібна сумісність з обома версіями.
useReducer найкраще поєднується з дискримінованим union дій: поле type визначає, які ще поля має дія.
type CartItem = { id: number; title: string; price: number; qty: number };
type CartState = {
items: CartItem[];
coupon: string | null;
};
type CartAction =
| { type: 'added'; item: CartItem }
| { type: 'removed'; id: number }
| { type: 'quantityChanged'; id: number; qty: number }
| { type: 'couponApplied'; code: string }
| { type: 'cleared' };
function cartReducer(state: CartState, action: CartAction): CartState {
switch (action.type) {
case 'added':
return { ...state, items: [...state.items, action.item] };
case 'removed':
return { ...state, items: state.items.filter((i) => i.id !== action.id) };
case 'quantityChanged':
return {
...state,
items: state.items.map((i) => (i.id === action.id ? { ...i, qty: action.qty } : i)),
};
case 'couponApplied':
return { ...state, coupon: action.code };
case 'cleared':
return { items: [], coupon: null };
default: {
const unreachable: never = action;
return state;
}
}
}
const [cart, dispatch] = useReducer(cartReducer, { items: [], coupon: null });
Що дає типізація:
- у кожній гілці
switchTypeScript звужує тип: уcase 'removed'доступнеaction.id, аaction.item- помилка; dispatchперевіряє дії:dispatch({ type: 'removed' })безidчи з неіснуючимtypeне скомпілюється;- перевірка повноти: присвоєння
neverуdefaultдає помилку компіляції, якщо додати новий тип дії й забути обробити його в редьюсері.
Тип стану й дій виводиться з функції-редьюсера, тож параметри useReducer вказувати явно не треба. Явна анотація повернення CartState у редьюсері ловить випадки, коли гілка повертає щось не те.
Назви дій - у минулому часі («що сталося»: added, couponApplied), як радить документація React, а не команди (ADD_ITEM).
Лінива ініціалізація - третій аргумент: useReducer(cartReducer, userId, createInitialCart), де createInitialCart(userId) повертає початковий стан. Тип аргументу перевіряється.
Пастка: мутувати стан у редьюсері (state.items.push(...)) TypeScript не заборонить - для гарантій незмінності типи можна оголосити як readonly (readonly CartItem[]), або використати Immer (useImmerReducer).
createContext потребує значення за замовчуванням. Для контексту з реальними даними (поточний користувач, кошик) осмисленого значення за замовчуванням немає, тому часто пишуть null:
type AuthContextValue = {
user: User;
logout: () => void;
};
const AuthContext = createContext<AuthContextValue | null>(null);
Тепер кожен useContext(AuthContext) повертає AuthContextValue | null, і в кожному компоненті потрібна перевірка. Набридливо - і перевірка однаково нічого не робить, бо провайдер завжди є.
Рішення - власний хук з перевіркою в одному місці:
export function useAuth(): AuthContextValue {
const context = useContext(AuthContext);
if (context === null) {
throw new Error('useAuth треба викликати всередині <AuthProvider>');
}
return context;
}
function Header() {
const { user, logout } = useAuth(); // AuthContextValue без null
return <button onClick={logout}>Вийти, {user.name}</button>;
}
Переваги:
- тип без
nullу компонентах; - зрозуміла помилка одразу, якщо компонент випадково відрендерили поза провайдером (замість
Cannot read properties of nullдесь далі); - контекст можна не експортувати - лише хук і провайдер. Так менше способів використати його неправильно.
Провайдер разом із логікою:
export function AuthProvider({ user, children }: { user: User; children: ReactNode }) {
const logout = useCallback(() => router.post('/logout'), []);
const value = useMemo(() => ({ user, logout }), [user, logout]);
return <AuthContext value={value}>{children}</AuthContext>;
}
У React 19 контекст можна рендерити напряму як провайдер - <AuthContext value={...}> замість <AuthContext.Provider value={...}>.
Альтернатива, коли розумне значення за замовчуванням є (тема, мова): createContext<Theme>('light') - перевірки на null не потрібні взагалі.
Пастка: createContext<AuthContextValue>(null!) чи {} as AuthContextValue - «заглушка» для компілятора. TypeScript замовкне, але компонент поза провайдером отримає null чи порожній об'єкт і впаде з незрозумілою помилкою під час виконання.
React Compiler (стабільна версія 1.0) - інструмент етапу збирання, що автоматично додає мемоізацію в компоненти й хуки. Він аналізує код і кешує JSX, обчислення й функції так, щоб при повторному рендері перераховувалось лише те, що залежить від змінених даних.
До компілятора:
const Products = memo(function Products({ items, onSelect }) {
const sorted = useMemo(() => [...items].sort(byPrice), [items]);
const handleClick = useCallback((item) => onSelect(item.id), [onSelect]);
return sorted.map((item) => <Item key={item.id} onClick={() => handleClick(item)} />);
});
З компілятором - той самий код без memo, useMemo, useCallback. Компілятор мемоізує навіть те, що вручну зробити складно: стрілкову функцію () => handleClick(item) у циклі, яка з ручним useCallback все одно створювалась би щоразу й ламала б memo дочірнього компонента.
На чому фокусується:
- пропуск каскадних рендерів: якщо props дочірнього компонента не змінилися, він не рендериться;
- пропуск дорогих обчислень у компонентах і хуках.
Компілятор не мемоізує звичайні функції поза компонентами й хуками і не кешує результати між різними екземплярами компонента.
Правила React, на які він покладається:
- рендер чистий: компонент для тих самих props і стану повертає той самий результат, без побічних ефектів;
- props і стан незмінні: не мутувати отримані об'єкти й масиви;
- значення з хуків незмінні,
ref.currentне читається під час рендеру; - правила хуків (без умовних викликів).
Код, що порушує правила, компілятор пропускає (залишає без оптимізації), а не ламає. Порушення показує eslint-plugin-react-hooks.
Підключення: Babel-плагін babel-plugin-react-compiler (у Vite - через reactCompilerPreset плагіна React), підтримка в Next.js, Expo. Працює з React 17+ (для 17-18 - пакет react-compiler-runtime).
Що змінюється для розробника:
- нові компоненти пишуться без ручної мемоізації;
- існуючі
useMemo/useCallbackможна лишити - компілятор їх враховує; прибирати варто обережно, бо деякі з них впливають на залежності ефектів; - структурні оптимізації (віртуалізація, розділення коду, опускання стану) компілятор не замінює.
Питання з реальних технічних співбесід - 100 питань у 8 темах, розібраних із відповідями. Нижче - розбивка за рівнями та темами, якщо хочете звузити підготовку.
- Теми
- Хуки 15 Рендеринг 15 Стан і дані 12 Форми й Actions 12 Next.js, Inertia й маршрутизація 12 TypeScript та інструменти 12 Продуктивність 12 Тестування 10
Готуєтесь до співбесіди не просто так: зараз на сайті 145 відкритих вакансій Laravel і PHP. Переглянути вакансії