Питання на співбесіді з 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: компонент підписується лише на потрібне поле й рендериться лише при його зміні.
Що варто пам'ятати: контекст добре підходить для рідко змінюваних даних - тема, мова, поточний користувач, налаштування. Проблеми з продуктивністю зазвичай починаються, коли в контекст кладуть швидко змінюваний стан.
Відрендерити 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 лишається повним. Простий виграш для довгих сторінок з помірною кількістю елементів.
Віртуалізація потрібна, коли весь набір справді має бути доступний прокруткою: логи, таблиці даних, великі чати.
<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 і причинами рендерів.
Багато змін в інтерфейсі відбуваються не одразу: після відповіді сервера, таймера, переходу (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()), тож можна переконатися, що форма відправила правильні дані.
Хук не можна викликати поза компонентом. 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, // знімок для серверного рендеру
);
}
Три аргументи:
subscribe(callback)- підписатися на зміни й повернути функцію відписки;getSnapshot()- поточне значення;getServerSnapshot()- значення для серверного рендеру й гідратації (на сервері немаєnavigator).
Ключові правила:
getSnapshotмає повертати те саме значення, доки дані не змінилися. Якщо він щоразу створює новий об'єкт (() => ({ ...store.state })), React вважатиме, що дані змінилися на кожному рендері, - нескінченний цикл і помилка. Повертайте збережене незмінне значення або примітив;subscribe- стабільна функція, оголошена поза компонентом (чи вuseCallback), інакше React перепідписуватиметься на кожному рендері;- оновлення з такого джерела не можна позначити як transition - вони завжди синхронні.
Хто вже використовує: Redux (useSelector), Zustand, TanStack Query та інші бібліотеки стану побудовані на ньому. У застосунку цей хук зазвичай потрібен для API браузера чи власного сховища.
Приклад із localStorage: підписка на подію storage (зміни з інших вкладок) плюс власна подія для змін у поточній вкладці - і стан синхронізований між вкладками.
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.
Кожен рендер компонента - окремий виклик функції зі своїми значеннями 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 будується на тому ж механізмі.
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) або змінився контекст, який він читає.
Питання з реальних технічних співбесід - 100 питань у 8 темах, розібраних із відповідями. Нижче - розбивка за рівнями та темами, якщо хочете звузити підготовку.
- Теми
- Хуки 15 Рендеринг 15 Стан і дані 12 Форми й Actions 12 Next.js, Inertia й маршрутизація 12 TypeScript та інструменти 12 Продуктивність 12 Тестування 10
Готуєтесь до співбесіди не просто так: зараз на сайті 145 відкритих вакансій Laravel і PHP. Переглянути вакансії