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

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

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

5 питань

Конкурентний рендеринг (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