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

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

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

100 питань

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

Пастка 1 - новий об'єкт на кожен рендер провайдера:

function AuthProvider({ children }) {
  const [user, setUser] = useState(null);
  const login = async (data) => { /* ... */ };

  // новий об'єкт і нова функція при КОЖНОМУ рендері AuthProvider
  return <AuthContext value={{ user, login }}>{children}</AuthContext>;
}

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

const login = useCallback(async (data) => { /* ... */ }, []);
const value = useMemo(() => ({ user, login }), [user, login]);
return <AuthContext value={value}>{children}</AuthContext>;

React Compiler робить цю мемоізацію автоматично.

Пастка 2 - один контекст для даних, що змінюються з різною частотою. Тема, користувач і стан кошика в одному контексті: додавання товару перерендерює все, що читає тему.

Рішення - розділити контексти:

<ThemeContext value={theme}>
  <UserContext value={user}>
    <CartContext value={cart}>{children}</CartContext>
  </UserContext>
</ThemeContext>

Розділити стан і дії: компоненти, що лише змінюють стан (кнопка «Додати в кошик»), не мають рендеритися, коли стан змінюється:

<CartStateContext value={items}>
  <CartActionsContext value={actions}>   {/* стабільний об'єкт, не змінюється */}
    {children}
  </CartActionsContext>
</CartStateContext>

Коли контексту замало. Контекст - механізм передачі значення, а не сховище з підпискою на частину стану. Для великого стану, що часто змінюється (редактор, складна форма, дані в реальному часі), краще бібліотеки з селекторами - Zustand, Jotai, Redux Toolkit, useSyncExternalStore: компонент підписується лише на потрібне поле й рендериться лише при його зміні.

Що варто пам'ятати: контекст добре підходить для рідко змінюваних даних - тема, мова, поточний користувач, налаштування. Проблеми з продуктивністю зазвичай починаються, коли в контекст кладуть швидко змінюваний стан.

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

Відрендерити 20 000 рядків таблиці - це 20 000 компонентів і сотні тисяч DOM-вузлів. Повільно все: перший рендер, кожне оновлення, прокрутка, пам'ять. memo тут не рятує - проблема в самій кількості.

Віртуалізація (windowing) - рендерити лише рядки, видимі на екрані, плюс невеликий запас. При прокрутці ті самі DOM-вузли отримують нові дані.

import { useVirtualizer } from '@tanstack/react-virtual';

function VacancyList({ items }) {
  const parentRef = useRef<HTMLDivElement>(null);

  const virtualizer = useVirtualizer({
    count: items.length,
    getScrollElement: () => parentRef.current,
    estimateSize: () => 72,   // орієнтовна висота рядка
    overscan: 5,
  });

  return (
    <div ref={parentRef} style={{ height: 600, overflow: 'auto' }}>
      <div style={{ height: virtualizer.getTotalSize(), position: 'relative' }}>
        {virtualizer.getVirtualItems().map((row) => (
          <div
            key={row.key}
            style={{ position: 'absolute', top: 0, transform: `translateY(${row.start}px)`, width: '100%' }}
          >
            <VacancyRow vacancy={items[row.index]} />
          </div>
        ))}
      </div>
    </div>
  );
}

У DOM постійно ~15 рядків замість 20 000, а висота контейнера дорівнює повній висоті списку - смуга прокрутки виглядає правильно.

Бібліотеки: TanStack Virtual (без готової розмітки, гнучкий), react-window, react-virtuoso (вміє динамічну висоту й «прилипання» до низу для чатів).

Складнощі, які треба врахувати:

  • рядки різної висоти - вимірювати після рендеру (measureElement) і оцінювати заздалегідь;
  • пошук браузера (Ctrl+F) не знайде текст рядків, яких немає в DOM;
  • доступність: зчитувачі екрана бачать лише відрендерені рядки - потрібні aria-rowcount/aria-rowindex;
  • збереження позиції прокрутки при поверненні на сторінку.

Альтернативи, які часто кращі:

  • пагінація чи «завантажити ще» - користувач рідко переглядає 20 000 рядків;
  • фільтри й пошук на сервері - показувати лише релевантне;
  • CSS content-visibility: auto - браузер пропускає розкладку й малювання позаекранних блоків, хоча DOM лишається повним. Простий виграш для довгих сторінок з помірною кількістю елементів.

Віртуалізація потрібна, коли весь набір справді має бути доступний прокруткою: логи, таблиці даних, великі чати.

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

<Profiler> вимірює час рендеру частини дерева з коду - для автоматичного збору метрик, а не ручного аналізу в DevTools.

import { Profiler, type ProfilerOnRenderCallback } from 'react';

const onRender: ProfilerOnRenderCallback = (id, phase, actualDuration, baseDuration, startTime, commitTime) => {
  if (actualDuration > 16) {
    metrics.record('slow-render', { id, phase, actualDuration, baseDuration });
  }
};

<Profiler id="VacancyTable" onRender={onRender}>
  <VacancyTable />
</Profiler>

Параметри колбеку:

  • id - назва профілера (для розрізнення кількох);
  • phase - "mount", "update" чи "nested-update" (оновлення, викликане зміною стану в ефекті під час коміту);
  • actualDuration - скільки мілісекунд зайняв рендер цього дерева в цьому коміті. Якщо мемоізація працює, він помітно менший за baseDuration;
  • baseDuration - оцінка часу рендеру всього дерева без оптимізацій (сума останніх часів рендеру всіх компонентів). Показує «найгірший випадок»;
  • startTime, commitTime - мітки часу початку рендеру й коміту.

Кілька профілерів можна ставити поруч і вкладати - щоб порівняти частини сторінки.

Важливо про production: профілювання додає накладних витрат, тому в звичайній production-збірці Profiler вимкнено - колбек не викликається. Для вимірювання на продакшені потрібна спеціальна збірка з профілюванням (react-dom/profiling), яку роздають частині користувачів чи використовують на тестовому стенді. У режимі розробки цифри завищені й годяться лише для відносних порівнянь.

Для чого використовують:

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

Обмеження:

  • Profiler міряє лише час рендеру React, а не завантаження даних, розкладку браузера чи малювання. Для повної картини взаємодії - метрика INP і вкладка Performance браузера;
  • не обгортати кожен компонент - профілер сам додає витрати; ставити на межі великих блоків.

Альтернатива для інтерактивного аналізу - вкладка Profiler у React DevTools з тими самими даними, flamegraph і причинами рендерів.

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

Багато змін в інтерфейсі відбуваються не одразу: після відповіді сервера, таймера, переходу (startTransition). Синхронна перевірка відразу після дії їх не побачить.

findBy... - чекає, поки елемент з'явиться (за замовчуванням до 1000 мс, перевіряючи кожні 50 мс):

test('завантажує вакансії', async () => {
  render(<VacancyList />);

  expect(screen.getByText('Завантаження...')).toBeInTheDocument();
  expect(await screen.findByRole('heading', { name: 'Laravel-розробник' })).toBeInTheDocument();
});

findBy = waitFor + getBy, і це найпростіший спосіб дочекатися появи елемента.

waitFor - повторює колбек, поки він не перестане кидати помилку:

await user.click(screen.getByRole('button', { name: 'Зберегти' }));
await waitFor(() => expect(saveMock).toHaveBeenCalledWith({ title: 'Нова' }));

Правила для waitFor:

  • одна перевірка всередині - якщо кілька, при падінні незрозуміло, яка не дочекалася;
  • без побічних ефектів у колбеку: він виконується багато разів (клік у waitFor клацне кілька разів);
  • для появи елемента - findBy, а не waitFor(() => getBy...);
  • для зникнення - waitForElementToBeRemoved(() => screen.queryByText('Завантаження...')).

Попередження «not wrapped in act(...)» означає: стан компонента оновився після того, як тест уже щось перевірив, і поза контролем тестових утиліт. Типові причини:

  • запит завершився після закінчення тесту - тест не дочекався кінцевого стану. Рішення - дочекатися через findBy того, що з'являється наприкінці;
  • таймер чи проміс оновлює стан, а тест не знає про це.

Не варто обгортати все в act вручну. render, userEvent, fireEvent, findBy, waitFor уже використовують act. Явний act потрібен рідко - наприклад, при виклику функції з renderHook чи прямому оновленні зовнішнього сховища.

Фальшиві таймери (vi.useFakeTimers()) прискорюють тести з затримками, але з userEvent потрібен параметр advanceTimers, а findBy/waitFor у сучасних версіях Testing Library самі просувають фальшиві таймери Jest; з Vitest це залежить від налаштування - простіше уникати фальшивих таймерів там, де можна.

Ознака поганого тесту - довільні затримки (await new Promise((r) => setTimeout(r, 500))): повільно й нестабільно.

Докладніше в документації: Testing Library: асинхронні методи

Тести компонентів, що завантажують дані, не повинні ходити в справжній API: повільно, нестабільно, залежить від стану бази. Підмінити fetch через vi.mock можна, але тоді тест прив'язаний до того, як код робить запит.

MSW (Mock Service Worker) перехоплює запити на рівні мережі: компонент викликає звичайний fetch чи axios, а відповідь надходить з обробника тесту.

// src/test/handlers.ts
import { http, HttpResponse } from 'msw';

export const handlers = [
  http.get('/api/vacancies', () =>
    HttpResponse.json([{ id: 1, title: 'Laravel-розробник' }]),
  ),
  http.post('/api/vacancies/:id/apply', async ({ params, request }) => {
    const body = await request.json();
    return HttpResponse.json({ id: params.id, email: body.email }, { status: 201 });
  }),
];
// src/test/setup.ts
import { setupServer } from 'msw/node';
import { handlers } from './handlers';

export const server = setupServer(...handlers);

beforeAll(() => server.listen({ onUnhandledRequest: 'error' }));
afterEach(() => server.resetHandlers());
afterAll(() => server.close());

Інший сценарій в окремому тесті - перевизначити обробник:

test('показує помилку сервера', async () => {
  server.use(http.get('/api/vacancies', () => new HttpResponse(null, { status: 500 })));

  render(<VacancyList />);
  expect(await screen.findByRole('alert')).toHaveTextContent('Не вдалося завантажити');
});

resetHandlers() після тесту повертає типові обробники - сценарії не «протікають» між тестами.

Переваги:

  • тест не знає про спосіб запиту - перехід з fetch на axios чи TanStack Query не ламає тести;
  • ті самі обробники працюють у браузері (через Service Worker) - для розробки фронтенду без готового бекенду, в Storybook і в браузерних тестах;
  • onUnhandledRequest: 'error' - тест падає на запиті, для якого немає обробника, замість тихого звернення в мережу.

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

  • відносні URL (/api/...) у середовищі jsdom розв'язуються відносно location тесту;
  • затримки й мережеві помилки - await delay(1000) і HttpResponse.error() для перевірки станів завантаження й обриву з'єднання;
  • перевіряти запит: тіло й заголовки читаються в обробнику (await request.json()), тож можна переконатися, що форма відправила правильні дані.

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

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

import { renderHook, act } from '@testing-library/react';

function useCounter(initial = 0) {
  const [count, setCount] = useState(initial);
  return { count, increment: () => setCount((c) => c + 1) };
}

test('збільшує лічильник', () => {
  const { result } = renderHook(() => useCounter(5));

  expect(result.current.count).toBe(5);

  act(() => result.current.increment());

  expect(result.current.count).toBe(6);
});

Ключові моменти:

  • result.current - значення, повернене хуком при останньому рендері. Не можна зберегти const { count } = result.current на початку - значення застаріє;
  • act потрібен для виклику функцій, що оновлюють стан, - так оновлення застосується до наступної перевірки;
  • зміна аргументів - через rerender:
const { result, rerender } = renderHook(({ id }) => useUser(id), { initialProps: { id: 1 } });
rerender({ id: 2 });
  • демонтування - unmount(), щоб перевірити очищення (відписка, скасування запиту).

Асинхронні хуки - з waitFor:

const { result } = renderHook(() => useVacancies());
await waitFor(() => expect(result.current.status).toBe('success'));

Хуки, що потребують провайдерів (контекст, роутер, клієнт TanStack Query) - опція wrapper:

const wrapper = ({ children }) => (
  <QueryClientProvider client={new QueryClient()}>{children}</QueryClientProvider>
);
const { result } = renderHook(() => useVacancies(), { wrapper });

Коли тестувати хук окремо, а коли через компонент:

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

Історія: раніше renderHook був в окремому пакеті @testing-library/react-hooks; для React 18+ він вбудований у @testing-library/react, а старий пакет не підтримується.

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

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

Найпростіше - wrapper у render:

render(<Header />, {
  wrapper: ({ children }) => <AuthContext value={{ user: testUser, logout: vi.fn() }}>{children}</AuthContext>,
});

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

// src/test/render.tsx
import { render, type RenderOptions } from '@testing-library/react';
import { MemoryRouter } from 'react-router';

type Options = RenderOptions & { user?: User | null; route?: string };

export function renderWithProviders(ui: ReactElement, { user = null, route = '/', ...options }: Options = {}) {
  const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });

  function Providers({ children }: { children: ReactNode }) {
    return (
      <QueryClientProvider client={queryClient}>
        <AuthContext value={{ user, logout: vi.fn() }}>
          <MemoryRouter initialEntries={[route]}>{children}</MemoryRouter>
        </AuthContext>
      </QueryClientProvider>
    );
  }

  return render(ui, { wrapper: Providers, ...options });
}

export * from '@testing-library/react';
import { renderWithProviders, screen } from '../test/render';

test('гість бачить кнопку входу', () => {
  renderWithProviders(<Header />, { user: null });
  expect(screen.getByRole('link', { name: 'Увійти' })).toBeInTheDocument();
});

Що варто врахувати:

  • свіжий стан на кожен тест: клієнт запитів, сховище (Zustand, Redux) створюються всередині функції, а не на рівні модуля - інакше кеш з одного тесту впливає на інший;
  • retry: false для TanStack Query - інакше тест помилки чекатиме повторних спроб;
  • маршрутизатор у пам'яті (MemoryRouter, createMemoryRouter) - без реального URL браузера; початковий маршрут задається параметром;
  • підміняти лише те, що поза межами тесту: справжній контекст з тестовими даними краще за мок useContext - тест перевіряє реальну інтеграцію;
  • перевірка поведінки при різному контексті (гість / адміністратор / інша мова) стає одним параметром.

renderHook приймає той самий wrapper - хуки тестуються з тими самими провайдерами.

Докладніше в документації: React Testing Library: власний render

Власний хук - функція з назвою на use, що викликає інші хуки. Спосіб винести логіку зі станом і ефектами з компонента й перевикористати.

function useDebouncedValue(value, delay = 300) {
  const [debounced, setDebounced] = useState(value);

  useEffect(() => {
    const id = setTimeout(() => setDebounced(value), delay);
    return () => clearTimeout(id);
  }, [value, delay]);

  return debounced;
}

function Search() {
  const [query, setQuery] = useState('');
  const debouncedQuery = useDebouncedValue(query);
  const results = useSearch(debouncedQuery);
  // ...
}

Ключове: хуки ділять логіку, а не стан. Кожен компонент, що викликає useDebouncedValue, отримує свій незалежний стан. Для спільного стану потрібен контекст чи стор.

Ознаки, що варто виділити хук:

  • той самий набір useState + useEffect повторюється в кількох компонентах;
  • ефект синхронізується з чимось зовнішнім (useOnlineStatus, useMediaQuery, useWebSocket) - хук ховає деталі за зрозумілою назвою;
  • компонент став довгим, і логіка заважає читати розмітку.

Чого уникати:

  • Хуків-обгорток життєвого циклу (useMount, useUpdateEffect): вони ховають залежності від лінтера й заохочують мислення класовими компонентами.
  • Хуків, що лише групують useState без поведінки - виграшу немає.
  • Нестабільних результатів: функції, які хук повертає й які передаватимуть у залежності ефектів, обгортають у useCallback, інакше ефекти споживачів перезапускатимуться на кожен рендер.

Тестують хуки через renderHook з React Testing Library.

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

Context передає значення від провайдера до будь-якого нащадка без props на кожному рівні: тема, мова, поточний користувач, налаштування.

const ThemeContext = createContext('light');

<ThemeContext value={theme}>   {/* React 19; раніше ThemeContext.Provider */}
  <App />
</ThemeContext>

const theme = useContext(ThemeContext);

Чому всі споживачі перерендерюються: коли значення провайдера змінилося (порівняння через Object.is), React перерендерює кожен компонент, що читає цей контекст, навіть якщо використовує лише частину значення. Вибіркової підписки на поле в контексту немає.

Типова пастка - новий об'єкт на кожен рендер:

<AuthContext value={{ user, login, logout }}>

Кожен рендер провайдера створює новий об'єкт - і всі споживачі перерендерюються, навіть якщо user не змінився.

Як з цим жити:

  • Мемоізувати значення: const value = useMemo(() => ({ user, login, logout }), [user]), функції - через useCallback.
  • Розділяти контексти: дані, що часто змінюються, окремо від рідкісних; стан окремо від функцій-диспетчерів (StateContext і DispatchContext).
  • Не тримати в контексті часто змінюваний стан великої частини застосунку (значення полів форми, позиція курсору). Для такого краще стор з селекторами (Zustand, Redux, Jotai), де компонент підписується лише на потрібну частину.

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

Докладніше в документації: Передача даних глибоко через Context

Більшість стану живе в React (useState, useReducer). Але інколи дані зберігаються зовні: у сторонній бібліотеці стану, в API браузера (navigator.onLine, matchMedia, localStorage), у власному класі-сховищі.

Наївний підхід - useEffect з підпискою і копією в useState - має проблеми в конкурентному рендерингу: React може рендерити частини дерева в різний час, і різні компоненти побачать різні версії зовнішнього значення («розрив», tearing).

useSyncExternalStore - офіційний спосіб читати зовнішнє джерело узгоджено:

import { useSyncExternalStore } from 'react';

function subscribe(callback) {
  window.addEventListener('online', callback);
  window.addEventListener('offline', callback);
  return () => {
    window.removeEventListener('online', callback);
    window.removeEventListener('offline', callback);
  };
}

export function useOnlineStatus() {
  return useSyncExternalStore(
    subscribe,
    () => navigator.onLine,   // знімок на клієнті
    () => true,               // знімок для серверного рендеру
  );
}

Три аргументи:

  1. subscribe(callback) - підписатися на зміни й повернути функцію відписки;
  2. getSnapshot() - поточне значення;
  3. getServerSnapshot() - значення для серверного рендеру й гідратації (на сервері немає navigator).

Ключові правила:

  • getSnapshot має повертати те саме значення, доки дані не змінилися. Якщо він щоразу створює новий об'єкт (() => ({ ...store.state })), React вважатиме, що дані змінилися на кожному рендері, - нескінченний цикл і помилка. Повертайте збережене незмінне значення або примітив;
  • subscribe - стабільна функція, оголошена поза компонентом (чи в useCallback), інакше React перепідписуватиметься на кожному рендері;
  • оновлення з такого джерела не можна позначити як transition - вони завжди синхронні.

Хто вже використовує: Redux (useSelector), Zustand, TanStack Query та інші бібліотеки стану побудовані на ньому. У застосунку цей хук зазвичай потрібен для API браузера чи власного сховища.

Приклад із localStorage: підписка на подію storage (зміни з інших вкладок) плюс власна подія для змін у поточній вкладці - і стан синхронізований між вкладками.

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

use(resource) читає значення з проміса або контексту. На відміну від хуків, його можна викликати в умовах і циклах.

1. Читання проміса з Suspense:

import { use, Suspense } from 'react';

function Comments({ commentsPromise }) {
  const comments = use(commentsPromise);   // «призупиняє» компонент до виконання проміса
  return comments.map((c) => <p key={c.id}>{c.text}</p>);
}

function Post({ commentsPromise }) {
  return (
    <Suspense fallback={<p>Завантаження коментарів...</p>}>
      <Comments commentsPromise={commentsPromise} />
    </Suspense>
  );
}

Поки проміс виконується, показується fallback найближчого <Suspense>. Відхилений проміс потрапляє в найближчу межу помилок (error boundary).

Головна пастка - звідки береться проміс. Проміс, створений під час рендеру клієнтського компонента (use(fetch('/api/comments'))), буде новим на кожному рендері - React показуватиме fallback знову й знову. Проміс має бути стабільним:

  • створений у серверному компоненті й переданий клієнтському як prop (основний сценарій у Next.js);
  • закешований бібліотекою (TanStack Query, роутер з завантажувачами даних);
  • створений поза рендером (в обробнику події, в завантажувачі маршруту).

2. Читання контексту:

function Toolbar({ showTheme }) {
  if (showTheme) {
    const theme = use(ThemeContext);   // useContext тут заборонений правилами хуків
    return <ThemeBadge theme={theme} />;
  }
  return null;
}

Що відрізняє use від хуків:

  • можна викликати умовно і в циклах;
  • але лише в компонентах і хуках - не в звичайних функціях і не в try/catch (помилки обробляє error boundary, а не catch);
  • не створює власного стану.

У серверних компонентах use для промісів не потрібен - там можна просто await. use - для клієнтських компонентів, що отримують проміс.

Чим це краще за useEffect для даних: немає проміжного стану «ще не завантажено» в кожному компоненті, немає гонитви запитів у ефектах, а стани завантаження й помилки декларативно задаються межами Suspense і error boundary.

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

Кожен рендер компонента - окремий виклик функції зі своїми значеннями props і стану. Функції, створені під час рендеру (обробники, колбеки ефектів), «запам'ятовують» значення цього рендеру. Якщо функція живе довше за рендер, вона бачить старі значення - це застаріле замикання (stale closure).

Класичний приклад - інтервал:

function Timer() {
  const [count, setCount] = useState(0);

  useEffect(() => {
    const id = setInterval(() => {
      setCount(count + 1);   // count завжди 0 - замикання першого рендеру
    }, 1000);
    return () => clearInterval(id);
  }, []);   // ефект створено один раз

  return <p>{count}</p>;   // зупиняється на 1
}

Способи виправити:

1. Функціональне оновлення - не читати стан у замиканні взагалі:

setCount((c) => c + 1);

Найкращий варіант, коли нове значення залежить лише від попереднього.

2. Додати значення в залежності - ефект перезапуститься з новим замиканням:

useEffect(() => { /* ... */ }, [count]);

Коректно, але для інтервалу означає перестворення таймера щосекунди.

3. useEffectEvent - логіка, що має бачити свіжі значення, але не перезапускати ефект:

const onTick = useEffectEvent(() => setCount(count + step));
useEffect(() => {
  const id = setInterval(onTick, 1000);
  return () => clearInterval(id);
}, []);

4. useRef з поточним значенням - старий спосіб (ref оновлюється на кожному рендері, колбек читає ref.current). Працює, але useEffectEvent робить це явніше.

Де ще трапляється:

  • обробники сторонніх бібліотек, зареєстровані один раз (карта, WebSocket);
  • useCallback з неповними залежностями - мемоізована функція бачить старий стан;
  • асинхронні функції: після await значення змінних - ті, що були на момент виклику, навіть якщо стан уже змінився.

Профілактика: правило лінтера react-hooks/exhaustive-deps знаходить більшість застарілих замикань у ефектах і мемоізації. Попередження «missing dependency» - майже завжди справжня помилка, а не надмірна прискіпливість лінтера.

Докладніше в документації: Видалення залежностей ефекту

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

useTransition позначає оновлення стану як нетермінове (transition):

const [isPending, startTransition] = useTransition();
const [tab, setTab] = useState('posts');

function selectTab(next) {
  startTransition(() => {
    setTab(next);            // важкий рендер вкладки можна перервати
  });
}

{isPending && <Spinner />}

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

useDeferredValue - те саме, але для значення, яке ви отримуєте, а не встановлюєте:

const [query, setQuery] = useState('');
const deferredQuery = useDeferredValue(query);

<input value={query} onChange={(e) => setQuery(e.target.value)} />
<SearchResults query={deferredQuery} />   // memo-компонент

Поле оновлюється одразу, а важкий список рендериться з «відкладеним» значенням у фоні. Щоб це працювало, SearchResults має бути обгорнутий у memo.

Чим це відрізняється від debounce: немає фіксованої затримки. На швидкому пристрої результат з'являється майже одразу, на повільному - React просто не блокує введення.

Обмеження: transitions не роблять рендер швидшим - лише не дають йому блокувати важливіше. Якщо компонент рендериться секунду, треба оптимізувати сам рендер (віртуалізація, мемоізація). У React 19 startTransition приймає й async-функції, а useActionState будується на тому ж механізмі.

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

Server Components (RSC) виконуються лише на сервері (або під час збирання) і ніколи не потрапляють у JavaScript-бандл клієнта. Вони можуть бути асинхронними й напряму читати базу, файли, секрети.

// Server Component (за замовчуванням у Next.js App Router)
async function PostPage({ id }) {
  const post = await db.posts.find(id);       // запит прямо в компоненті
  return (
    <article>
      <h1>{post.title}</h1>
      <LikeButton postId={id} />               {/* клієнтський острівець */}
    </article>
  );
}
'use client';
// Client Component: стан, ефекти, обробники подій
export function LikeButton({ postId }) {
  const [liked, setLiked] = useState(false);
  return <button onClick={() => setLiked(!liked)}>♥</button>;
}

Відмінності:

  • Server: немає useState, useEffect, обробників подій і API браузера. Зате нуль байтів JavaScript на клієнті й доступ до серверних ресурсів без окремого API.
  • Client: звичайний React з інтерактивністю. Позначаються директивою 'use client' - вона задає межу: файл і все, що він імпортує, потрапляє в клієнтський бандл.

Чим це відрізняється від SSR: SSR рендерить увесь застосунок у HTML на сервері, але потім увесь код завантажується в браузер і гідратується. RSC - це компоненти, код яких у браузер не потрапляє взагалі. Їх використовують разом із SSR.

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

  • Server Component може рендерити Client Component, але не навпаки (крім передачі через children).
  • Props від сервера до клієнта мають бути серіалізовними: функції передати не можна, хіба що серверні дії ('use server').
  • RSC потребують фреймворку з підтримкою (Next.js, React Router з RSC) - у звичайному Vite-застосунку їх немає.

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

Оновлення екрана в React проходить три кроки:

  1. Тригер - перший рендер (root.render) або зміна стану (setState) компонента чи його предка;
  2. Рендер - React викликає функції компонентів, щоб дізнатися, що має бути на екрані. Для оновлень порівнює новий результат з попереднім (reconciliation) і визначає мінімальні зміни;
  3. Коміт - React застосовує зміни до DOM, потім браузер відмальовує екран і виконуються ефекти.

Важливі наслідки:

  • рендер - чисте обчислення. Його можна перервати, відкласти, виконати повторно або двічі (StrictMode). Тому в тілі компонента не можна мутувати зовнішні змінні, робити запити чи змінювати DOM - лише обчислювати JSX з props і стану;
  • рендер ≠ зміна DOM. Якщо результат рендеру той самий, DOM не чіпається. Зайві рендери коштують процесорного часу на обчислення, але не на роботу з DOM;
  • у конкурентному режимі рендер нетермінового оновлення (transition) можна перервати заради термінового (введення в поле), а коміт завжди синхронний і неподільний - користувач не бачить «половини» оновлення.

Групування оновлень (batching). Кілька setState в одному обробнику дають один рендер:

function handleClick() {
  setCount((c) => c + 1);
  setFlag((f) => !f);
  setItems([]);
  // один рендер після завершення обробника
}

З React 18 групування автоматичне скрізь: у таймерах, промісах, нативних обробниках подій. У React 17 і раніше воно працювало лише в обробниках подій React - setState у setTimeout давав окремий рендер на кожен виклик.

Черга оновлень. React обробляє оновлення по черзі: значення - замінює, функцію - викликає з результатом попереднього кроку:

setNumber(number + 5);      // замінити на 5
setNumber((n) => n + 1);    // 5 + 1
setNumber(42);              // замінити на 42
// результат 42

Коли потрібно оновити DOM негайно (виміряти щойно доданий елемент) - flushSync з react-dom. Це рідкісний виняток: він ламає групування й шкодить продуктивності.

Коли компонент перерендерюється: змінився його стан, перерендерився батько (навіть якщо props ті самі - якщо компонент не обгорнуто в memo і його не оптимізує React Compiler) або змінився контекст, який він читає.

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

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

Рівні
Junior 33 Middle 34 Senior 33

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