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 будується на тому ж механізмі.
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-застосунку їх немає.
Оновлення екрана в React проходить три кроки:
- Тригер - перший рендер (
root.render) або зміна стану (setState) компонента чи його предка; - Рендер - React викликає функції компонентів, щоб дізнатися, що має бути на екрані. Для оновлень порівнює новий результат з попереднім (reconciliation) і визначає мінімальні зміни;
- Коміт - 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 не працюють.
<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, змиряючись із працюючими ефектами.