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

Питання на співбесіді: Рендеринг

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

15 питань

JSX - синтаксичне розширення JavaScript, що дозволяє писати розмітку прямо в коді. Браузер його не розуміє: компілятор (Babel, esbuild, SWC, TypeScript) перетворює JSX на виклики функцій.

const element = <h1 className="title">Привіт, {user.name}</h1>;

// після компіляції (новий JSX transform)
import { jsx as _jsx } from 'react/jsx-runtime';
const element = _jsx('h1', { className: 'title', children: ['Привіт, ', user.name] });

Результат - звичайний об'єкт-опис («React element»), а не DOM-вузол. React потім вирішує, як привести DOM у відповідність.

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

  • className замість class, htmlFor замість for - бо це JavaScript-властивості.
  • Атрибути в camelCase: onClick, tabIndex; style - об'єкт: style={{ marginTop: 8 }}.
  • Кожен тег закривається: <img />, <br />.
  • Компонент повертає один корінь. Для кількох елементів - фрагмент <>...</>.
  • У фігурних дужках - вирази, не інструкції: {isAdmin && <Badge />}, {items.map(...)}, але не if чи for.

Безпека: значення в {} автоматично екрануються, тож рядок з <script> виведеться як текст. Небезпечний лише явний dangerouslySetInnerHTML.

Компонент - функція, що повертає JSX. Назва з великої літери обов'язкова: <profile /> React вважатиме HTML-тегом, <Profile /> - компонентом.

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

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

<ul>
  {todos.map((todo) => (
    <TodoItem key={todo.id} todo={todo} />
  ))}
</ul>

Без key React виводить попередження й зіставляє елементи за позицією.

З індексом як key - те саме зіставлення за позицією, лише без попередження. Поки список лише дописують у кінець, проблем немає. Після видалення, вставки на початок чи сортування:

  • стан компонентів (введений текст у полі, чекбокс, розгорнутість) «переїжджає» до сусіднього елемента;
  • React оновлює вміст багатьох елементів замість переміщення одного.

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

  • Стабільний: той самий для того самого елемента між рендерами - id з бази.
  • Унікальний серед сусідів (не глобально).
  • Не генерувати під час рендеру: key={Math.random()} чи crypto.randomUUID() змушують React щоразу перестворювати всі елементи й губити стан. Якщо в даних немає ID, його генерують один раз - при створенні запису.

Корисний прийом: зміна key компонента примусово перестворює його з чистим станом: <Profile key={userId} userId={userId} /> - форма скинеться при переході до іншого користувача.

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

У JSX немає директив на кшталт v-if - умови пишуть звичайним JavaScript.

Тернарний оператор - один із двох варіантів:

{isLoggedIn ? <UserMenu /> : <LoginButton />}

&& - показати або нічого:

{hasError && <ErrorMessage />}

Ранній return - для цілих станів компонента:

if (isLoading) return <Spinner />;
if (!user) return null;   // null - нічого не рендерити
return <Profile user={user} />;

Пастка з && і числами:

{items.length && <List items={items} />}

Якщо масив порожній, items.length дорівнює 0. Оператор && повертає ліву частину, коли вона хибна, - тобто 0. А React рендерить число 0 як текст. Користувач бачить загадковий «0» на сторінці.

React не рендерить лише null, undefined, false, true і порожній рядок. Число 0 і NaN він показує.

Як правильно:

{items.length > 0 && <List items={items} />}
{!!items.length && <List items={items} />}
{items.length ? <List items={items} /> : null}

Ліва частина && має бути логічним значенням, а не просто «чимось, що буває хибним».

Інші поради:

  • складні умови - у змінну чи окремий компонент, а не вкладені тернарні оператори в JSX;
  • мапа станів замість ланцюжка умов:
const content = {
  loading: <Spinner />,
  error: <ErrorMessage />,
  empty: <EmptyState />,
}[status] ?? <List items={items} />;
  • умова змінює дерево: якщо в гілках однакові компоненти на тому самому місці, React може зберегти їхній стан між гілками. Коли стан має скидатися, різні гілки отримують різний key.

Приховати, а не прибрати - hidden чи CSS-клас: компонент і його стан лишаються. У React 19.2+ для цього є й <Activity mode="hidden">.

Докладніше в документації: Умовний рендеринг

Props - аргументи компонента. Батько передає їх як атрибути JSX, дитина отримує одним об'єктом (зазвичай з деструктуризацією):

function Avatar({ user, size = 48 }) {
  return <img src={user.avatarUrl} alt={user.name} width={size} height={size} />;
}

<Avatar user={currentUser} size={64} />

Правила props:

  • лише для читання - компонент не змінює свої props. Якщо значення має змінюватися, це стан (у батька чи в самому компоненті);
  • значення за замовчуванням - у деструктуризації (size = 48); спрацьовує для undefined, але не для null;
  • розгортання <Avatar {...props} /> зручне для обгорток, але приховує, що саме передається, - використовувати помірно.

children - вміст між тегами компонента:

function Card({ title, children }) {
  return (
    <section className="rounded border p-4">
      <h2>{title}</h2>
      {children}
    </section>
  );
}

<Card title="Профіль">
  <Avatar user={user} />
  <p>{user.bio}</p>
</Card>

Композиція замість успадкування. У React не створюють class SpecialCard extends Card. Спеціалізація - через props і вкладення:

  • «слоти» - кілька props з JSX: <Layout sidebar={<Nav />} header={<Header />}>...</Layout>;
  • спеціалізований компонент рендерить загальний з потрібними props: function WarningCard(props) { return <Card {...props} tone="warning" />; };
  • поведінку перевикористовують через власні хуки, а не базові класи.

Чому це краще: компоненти незалежні, їх легко комбінувати в будь-якому порядку, а зміни в «базовому» компоненті не ламають ієрархію нащадків.

Корисний наслідок children для продуктивності: якщо дорогий компонент передано як children, батько, що змінює свій стан, не перерендерює його - JSX-елемент створено вище й не змінився.

Prop drilling - коли дані передаються через багато рівнів, які їх не використовують. Спершу варто спробувати композицію (передати готовий JSX вниз), а вже потім Context.

Докладніше в документації: Передача props компоненту

Компонент повертає один кореневий елемент JSX. Щоб повернути кілька елементів без зайвої обгортки в DOM, використовують Fragment:

function UserInfo({ user }) {
  return (
    <>
      <dt>Ім'я</dt>
      <dd>{user.name}</dd>
    </>
  );
}

<>...</> - скорочений запис <Fragment>...</Fragment>. У DOM потрапляють лише dt і dd.

Навіщо не обгортати в <div>:

  • валідний HTML: усередині <dl>, <ul>, <table>, <tr> допустимі лише певні елементи. <div> між <tr> і <td> - некоректна розмітка й попередження React;
  • CSS-розкладка: у flex- чи grid-контейнері зайвий <div> стає окремим елементом сітки й ламає розташування;
  • менше DOM - трохи легше для браузера на великих сторінках.

Fragment з key - коли фрагмент рендериться в циклі. Скорочений запис <> атрибутів не приймає, потрібна повна форма:

import { Fragment } from 'react';

function Glossary({ items }) {
  return (
    <dl>
      {items.map((item) => (
        <Fragment key={item.id}>
          <dt>{item.term}</dt>
          <dd>{item.description}</dd>
        </Fragment>
      ))}
    </dl>
  );
}

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

  • key - єдиний атрибут, який приймає Fragment (в експериментальних версіях з'являється ще ref);
  • Fragment не має власного DOM-вузла - на нього не можна повісити обробник події чи клас;
  • збереження стану: React однаково трактує <> з дітьми і масив дітей на верхньому рівні, тож перехід між <><Child /></> і <Child /> не скидає стан. А от зміна позиції в дереві чи key - скидає.

Повернути масив (return [<li key="a" />, <li key="b" />]) теж можна, але потрібні key на кожному елементі - Fragment читається простіше.

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

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

  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 - централізоване логування помилок, перехоплених межами і неперехоплених.

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

Конкурентний рендеринг (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) або змінився контекст, який він читає.

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

<Suspense> показує fallback, поки щось усередині «не готове»:

  • компонент, що читає незавершений проміс через use();
  • лінивий компонент (lazy(() => import(...))), чий код ще завантажується;
  • дані фреймворку з підтримкою Suspense (серверні компоненти, завантажувачі Next.js, TanStack Query в режимі useSuspenseQuery).
<Suspense fallback={<PageSkeleton />}>
  <ProfileHeader />
  <Suspense fallback={<PostsSkeleton />}>
    <Posts />
  </Suspense>
</Suspense>

Як поводяться межі:

  • «призупинений» компонент показує fallback найближчої межі вгору по дереву;
  • уся межа відображає fallback, поки не готовий хоча б один компонент усередині, - не лише той, що чекає;
  • вкладені межі дають поступове відкриття: спершу сторінка зі скелетом постів, потім пости.

Проєктування станів завантаження - питання UX, а не техніки:

  • межі за змістовими блоками, а не на кожному компоненті. Двадцять незалежних спінерів, що з'являються в довільному порядку, гірші за один скелет сторінки;
  • те, що має з'являтися разом, - в одній межі (заголовок і обкладинка), те, що може відстати, - в окремій (коментарі, рекомендації);
  • fallback - скелет того самого розміру, щоб сторінка не стрибала при появі вмісту.

Повторне завантаження без «блимання»: якщо вже показаний вміст знову призупиняється (новий запит при переході між вкладками), межа заховала б готовий вміст за fallback. Щоб цього уникнути, оновлення позначають як transition (startTransition, useTransition) - React залишає старий вміст на екрані, поки готується новий. Також useDeferredValue для значень з введення.

Помилки: відхилений проміс у межі Suspense іде до найближчого error boundary. Зазвичай їх ставлять поруч: межа помилок навколо межі Suspense.

Серверний рендер і стримінг: з renderToPipeableStream/RSC сервер одразу відправляє HTML з fallback-ами, а готові частини дописує в потік. Межі Suspense визначають, які частини сторінки можуть приходити пізніше, - і селективну гідратацію (React гідратує спочатку ту частину, з якою користувач взаємодіє).

Чого Suspense не робить сам по собі: не завантажує дані. Він лише реагує на «призупинення» - джерело даних має його підтримувати (use зі стабільним промісом, фреймворк, бібліотека). useEffect + useState з Suspense не працюють.

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

<Activity> приховує й відновлює частину інтерфейсу разом з її станом. Це проміжний варіант між «показати» і «прибрати з дерева».

import { Activity } from 'react';

<Activity mode={tab === 'comments' ? 'visible' : 'hidden'}>
  <Comments />
</Activity>

Порівняння трьох підходів:

Підхід Стан Ефекти DOM
{show && <Comments />} губиться при приховуванні очищаються видаляється
CSS hidden зберігається продовжують працювати лишається
<Activity mode="hidden"> зберігається очищаються (підписки знімаються) лишається, display: none

Що відбувається в прихованому режимі:

  • діти приховуються через display: none;
  • ефекти знищуються - таймери, підписки, з'єднання закриваються, ніби компонент розмонтовано;
  • стан і DOM зберігаються - введений текст, позиція прокрутки, розгорнуті секції;
  • компоненти продовжують перерендерюватися при зміні props, але з нижчим пріоритетом, ніж видимий вміст;
  • при поверненні у visible ефекти створюються знову, стан на місці.

Застосування:

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

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

  • ефекти мають правильно прибирати за собою - Activity активно використовує очищення ефектів. Компонент, що не відписується в очищенні, при приховуванні «протікатиме» (те, що й так ловить StrictMode);
  • пам'ять: прихований вміст лишається в DOM і пам'яті. Десятки прихованих важких екранів - помітне навантаження;
  • медіа: відео й аудіо в прихованому вмісті продовжують грати, якщо не зупинити їх в очищенні ефекту;
  • прихована Activity, що рендерить лише текст, не рендерить нічого - немає DOM-елемента, якому призначити display: none.

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

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