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

Middle: питання на співбесіді з теми «Рендеринг»

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

5 питань

Компонент рендериться повторно, коли:

  1. змінився його стан (useState, useReducer);
  2. змінився контекст, який він читає;
  3. перерендерився батько - за замовчуванням разом з ним рендеряться всі дочірні компоненти, незалежно від того, чи змінилися їхні props.

Третій пункт часто дивує. Але рендер - це лише виклик функції й порівняння віртуального DOM; реальний DOM змінюється тільки там, де щось справді змінилося. Зазвичай це дешево.

React.memo пропускає рендер компонента, якщо його props поверхнево не змінилися (Object.is для кожного prop):

const Chart = memo(function Chart({ data, onSelect }) {
  /* важкий рендер */
});

Чому memo часто «не працює»: батько передає новий об'єкт чи функцію на кожен рендер:

<Chart data={rows.filter(isVisible)} onSelect={(id) => select(id)} />
// і data, і onSelect - нові посилання щоразу, memo марний

Тут потрібні useMemo для data і useCallback для onSelect - або передавати примітиви.

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

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

React Compiler (стабільний з 2025 року) автоматично мемоізує компоненти й значення, і там, де його ввімкнено, ручні memo/useMemo/useCallback здебільшого не потрібні.

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

  • useMemo(fn, deps) - кешує результат обчислення між рендерами, доки залежності не змінились.
  • useCallback(fn, deps) - кешує саму функцію (те саме посилання). По суті, useMemo(() => fn, deps).
const visible = useMemo(
  () => todos.filter((t) => matches(t, filter)),
  [todos, filter],
);

const handleSelect = useCallback((id) => setSelected(id), []);

Коли вони потрібні:

  1. Справді дороге обчислення - фільтрація чи сортування тисяч елементів, побудова дерева. Перевірити легко: console.time навколо обчислення; якщо це мілісекунди й більше - є сенс.
  2. Стабільне посилання для дочірнього компонента з memo - інакше нові об'єкт чи функція на кожен рендер скасовують мемоізацію.
  3. Значення в залежностях ефекту - функція чи об'єкт, створені заново на кожен рендер, перезапускали б useEffect щоразу.

Коли зайві:

  • Прості обчислення (a + b, items.length, форматування рядка) - мемоізація коштує більше за них.
  • Колбеки для звичайних DOM-елементів (<button onClick={...}>) - кнопці байдуже, нова це функція чи ні.
  • «Про всяк випадок» скрізь - код важче читати, а неправильні залежності створюють баги зі застарілими значеннями.

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

React Compiler робить цю мемоізацію автоматично на етапі збирання. У проєктах, де він увімкнений, ручні useMemo і useCallback здебільшого прибирають.

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

Стан зберігається не «в компоненті», а в позиції компонента в дереві. React зберігає стан, доки на тому самому місці дерева рендериться той самий тип компонента.

{isFancy ? <Counter fancy /> : <Counter />}

Перемикання isFancy не скидає лічильник: на тому самому місці той самий тип Counter, змінилися лише props.

Стан скидається, коли:

  • на тому самому місці з'являється інший тип компонента (<Counter /> → <Timer />) або інший тег (<div> → <section> - скидається все піддерево);
  • компонент прибрано з дерева і повернуто;
  • змінився key.

key для скидання стану - найважливіший практичний прийом:

<ContactForm key={contact.id} contact={contact} />

Перемкнули контакт - змінився key - React створює новий екземпляр форми з чистим станом. Без key форма зберегла б введений текст від попереднього контакту.

Це заміна поширеному антипатерну «скинути стан в ефекті при зміні props»:

// погано: зайвий рендер зі старими даними, потім скидання
useEffect(() => { setDraft(''); }, [contact.id]);

Пастка - компонент, оголошений усередині компонента:

function Page() {
  const [text, setText] = useState('');

  function Field() {   // нова функція на кожному рендері Page
    return <input value={text} onChange={(e) => setText(e.target.value)} />;
  }

  return <Field />;
}

На кожному рендері Field - новий тип компонента, тож React розмонтовує старий і монтує новий: поле губить фокус після кожної літери. Компоненти оголошують на верхньому рівні модуля.

Зворотний бік - стан «прилипає», коли це не очікується: однакова позиція в різних гілках умови, однаковий key у різних списках. Тоді допомагає різний key для гілок.

Зберегти стан прихованого компонента (вкладки, бічна панель) - не прибирати його з дерева: CSS-приховування, <Activity mode="hidden"> або винести стан угору до батька.

Докладніше в документації: Збереження й скидання стану

Портал рендерить дочірні елементи в інший вузол DOM, ніж той, де знаходиться компонент:

import { createPortal } from 'react-dom';

function Modal({ open, onClose, children }) {
  if (!open) return null;

  return createPortal(
    <div className="fixed inset-0 grid place-items-center bg-black/50" onClick={onClose}>
      <div role="dialog" aria-modal="true" onClick={(e) => e.stopPropagation()}>
        {children}
      </div>
    </div>,
    document.body,
  );
}

Навіщо: модальні вікна, підказки, випадні меню мають бути поверх усього. Якщо їхня розмітка глибоко в дереві, батьківські стилі заважають: overflow: hidden обрізає, transform ламає position: fixed, z-index обмежений контекстом накладання батька.

Ключова особливість - події. Портал змінює місце в DOM, але не в дереві React. Події спливають за деревом React:

<div onClick={() => console.log('клік у батьку')}>
  <Modal open>
    <button>Кнопка у вікні</button>   {/* клік спливе до div батька! */}
  </Modal>
</div>

Клік по кнопці, яка в DOM лежить у body, все одно викличе onClick батька. Те саме з контекстом: портал бачить провайдери свого предка в React, а не за місцем у DOM.

Це зручно (стан, контекст і обробники працюють як для звичайних дітей), але інколи несподівано - наприклад, «клік поза меню» через contains() у DOM вважатиме клік у порталі зовнішнім.

Що ще потрібно для якісного модального вікна (портал цього не дає):

  • фокус усередині вікна і повернення фокусу після закриття;
  • закриття по Escape;
  • блокування прокрутки сторінки;
  • aria-modal, aria-labelledby.

Нативна альтернатива - <dialog> з showModal(): браузер сам виносить вікно на верхній шар (без порталу й боротьби із z-index), дає фокус і Escape. Для багатьох випадків простіше за портал.

SSR: на сервері порталів у document.body немає - модальні вікна зазвичай рендерять лише на клієнті (після монтування).

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

Без обробки помилка під час рендеру будь-якого компонента розмонтовує весь застосунок - користувач бачить порожню сторінку. Межа помилок перехоплює помилки свого піддерева й показує запасний інтерфейс.

Межі помилок досі пишуться лише класами - хука-аналога немає:

class ErrorBoundary extends React.Component {
  state = { error: null };

  static getDerivedStateFromError(error) {
    return { error };   // наступний рендер покаже fallback
  }

  componentDidCatch(error, info) {
    reportError(error, info.componentStack);   // у моніторинг
  }

  render() {
    if (this.state.error) return this.props.fallback;
    return this.props.children;
  }
}
<ErrorBoundary fallback={<p>Віджет не завантажився</p>}>
  <RevenueChart />
</ErrorBoundary>

На практиці частіше беруть готовий пакет react-error-boundary - з функцією скидання (resetErrorBoundary), ключами для автоматичного скидання і хуком useErrorBoundary.

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

Чого НЕ ловить:

  • обробники подій (onClick) - там звичайний try/catch;
  • асинхронний код - setTimeout, проміси без use(), колбеки запитів;
  • помилки в самій межі - вони йдуть до наступної межі вище;
  • серверний рендер - на сервері помилки обробляються інакше.

Щоб передати помилку з обробника чи асинхронного коду в межу, її «кидають» під час рендеру: зберегти в стан і throw у рендері, або showBoundary(error) з react-error-boundary.

Де ставити межі:

  • верхня - на весь застосунок, щоб замість білого екрана був зрозумілий текст і кнопка оновлення;
  • навколо незалежних віджетів - графік, коментарі, рекомендації: зламаний віджет не зносить сторінку;
  • на рівні маршруту - роутери (React Router, Next.js error.tsx) мають власні межі.

React 19: опції кореня onCaughtError і onUncaughtError у createRoot - централізоване логування помилок, перехоплених межами і неперехоплених.

Докладніше в документації: Межі помилок