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

Питання на співбесіді з 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 зручний: сервер отримає масив.

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

Збереження стану між сесіями (тема, згорнута бічна панель, чернетка форми) - типова задача. Найпростіший варіант:

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 - вони вже враховують частину цих проблем.

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

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

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

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 і текстова зміна на кнопці, щоб стан відправки був зрозумілий не лише візуально.

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

Для простих форм вистачає нативних засобів 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.

Докладніше в документації: 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 (забагато спроб).

Докладніше в документації: React Hook Form: setError

У 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 з тегами, але вбудований у рендер сторінок.

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

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

Докладніше в документації: Inertia: спільні дані

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

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

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 під час рендеру (крім лінивої ініціалізації) - лише в обробниках подій та ефектах.

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

До 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, і це нормально - для них потрібна сумісність з обома версіями.

Докладніше в документації: 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 });

Що дає типізація:

  • у кожній гілці switch TypeScript звужує тип: у 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).

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

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 чи порожній об'єкт і впаде з незрозумілою помилкою під час виконання.

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

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

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

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

Рівні
Junior 33 Middle 34 Senior 33

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