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

Питання на співбесіді: Продуктивність

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

12 питань

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

1. Опустити стан туди, де він потрібен (state colocation):

// було: введення в пошук перерендерює всю сторінку
function Page() {
  const [query, setQuery] = useState('');
  return (
    <>
      <input value={query} onChange={(e) => setQuery(e.target.value)} />
      <Results query={query} />
      <HeavySidebar />      {/* рендериться на кожну літеру */}
    </>
  );
}

// стало: стан живе в компоненті, якому він потрібен
function Search() {
  const [query, setQuery] = useState('');
  return (
    <>
      <input value={query} onChange={(e) => setQuery(e.target.value)} />
      <Results query={query} />
    </>
  );
}

function Page() {
  return (
    <>
      <Search />
      <HeavySidebar />      {/* більше не залежить від введення */}
    </>
  );
}

2. Передати важкий вміст як children:

function Collapsible({ children }) {
  const [open, setOpen] = useState(false);
  return (
    <section>
      <button onClick={() => setOpen(!open)}>Розгорнути</button>
      {open && children}
    </section>
  );
}

<Collapsible>
  <HeavyChart />
</Collapsible>

Коли змінюється open, React перерендерює Collapsible, але children - це JSX, створений батьком, і він не змінився. HeavyChart не рендериться заново.

Інші принципи з документації React:

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

Рендер - не обов'язково проблема. Рендер - це виклик функції й порівняння результату; DOM змінюється лише там, де результат відрізняється. Оптимізувати варто тоді, коли профілювання показує помітні затримки, а не «про всяк випадок».

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

Докладніше в документації: memo: чи варто додавати memo всюди

Початкове значення useState використовується лише при першому рендері. Але вираз, переданий аргументом, обчислюється на кожному рендері - бо це звичайний виклик функції компонента.

function Editor() {
  // parseDraft викликається на КОЖНОМУ рендері, хоча результат потрібен один раз
  const [draft, setDraft] = useState(parseDraft(localStorage.getItem('draft')));
  // ...
}

Якщо обчислення дороге (розбір великого JSON, читання localStorage, побудова структури з тисяч елементів), компонент сповільнюється на кожне введення в поле.

Лінива ініціалізація - передати функцію, а не її результат:

const [draft, setDraft] = useState(() => parseDraft(localStorage.getItem('draft')));

React викличе функцію лише при першому рендері. Зверніть увагу на різницю:

useState(createInitialTodos());   // викликається щоразу
useState(createInitialTodos);     // передано саму функцію - лише раз
useState(() => createInitialTodos(userId));   // з аргументом - через обгортку

Те саме для useReducer - третій аргумент-ініціалізатор:

const [state, dispatch] = useReducer(reducer, userId, createInitialState);

Для useRef вбудованої лінивої ініціалізації немає - роблять вручну:

const playerRef = useRef<VideoPlayer | null>(null);
if (playerRef.current === null) {
  playerRef.current = new VideoPlayer();   // створюється один раз
}

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

  • у StrictMode функція-ініціалізатор у режимі розробки викликається двічі - вона має бути чистою (без запитів, підписок, побічних ефектів);
  • для дешевих значень (useState(0), useState([]), useState(props.initial)) лінива ініціалізація не потрібна - виграшу немає;
  • ініціалізація з props відбувається один раз: useState(props.value) не оновиться, коли props.value зміниться. Якщо значення має стежити за props, стан не потрібен - обчислюйте під час рендеру, а для «скидання» стану використайте key.

Докладніше в документації: useState: уникнення повторного створення початкового стану

За замовчуванням збирач кладе весь код застосунку в один файл: користувач, що відкрив головну сторінку, завантажує й код адмінки, редактора, графіків. Розділення коду (code splitting) виносить частини в окремі файли, що завантажуються за потреби.

lazy - компонент, код якого завантажується при першому рендері:

import { lazy, Suspense } from 'react';

const MarkdownEditor = lazy(() => import('./MarkdownEditor'));

function PostForm() {
  const [editing, setEditing] = useState(false);
  return (
    <>
      <button onClick={() => setEditing(true)}>Редагувати</button>
      {editing && (
        <Suspense fallback={<p>Завантаження редактора...</p>}>
          <MarkdownEditor />
        </Suspense>
      )}
    </>
  );
}

Збирач (Vite) бачить динамічний import() і виносить MarkdownEditor з усіма залежностями в окремий файл.

Suspense показує fallback, поки код завантажується. Одна межа Suspense може охоплювати кілька лінивих компонентів.

Правила:

  • lazy - на верхньому рівні модуля, не всередині компонента. Інакше на кожен рендер створюється новий компонент - стан губиться, і код завантажується знову;
  • модуль має експортувати компонент за замовчуванням (export default). Для іменованого експорту - проміжний модуль чи import('./x').then((m) => ({ default: m.Editor }));
  • помилка завантаження (мережа, файл зник після деплою) кидається як виняток - потрібна межа помилок (Error Boundary), щоб показати повідомлення й кнопку «Оновити».

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

  • сторінки й маршрути - найприродніша межа; маршрутизатори (React Router, TanStack Router) мають для цього вбудовану підтримку;
  • важкі бібліотеки - редактори, графіки, карти, PDF;
  • рідко потрібне - модальні вікна налаштувань, експорт.

Не варто дробити кожен компонент: багато дрібних файлів - багато запитів і затримки «водоспадом». Межі - за сценаріями використання.

Попереднє завантаження: щоб користувач не чекав після кліку, імпорт можна запустити заздалегідь - при наведенні на кнопку: onMouseEnter={() => import('./MarkdownEditor')}. Повторний import() використає вже завантажений модуль.

Inertia розділяє код за сторінками автоматично, якщо в resolve використовується import.meta.glob без eager: true.

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

React Developer Tools - розширення браузера (Chrome, Firefox, Edge) з двома вкладками: Components і Profiler.

Components - дерево компонентів зі станом, props і хуками кожного. Корисне для продуктивності тим, що:

  • показує, які props отримав компонент - видно, коли передається новий об'єкт чи функція на кожен рендер;
  • у налаштуваннях можна ввімкнути «Highlight updates when components render» - компоненти, що перерендерились, підсвічуються на сторінці. Одразу видно, що введення в поле перемальовує всю сторінку.

Profiler - запис сесії і аналіз:

  1. натиснути «Record», виконати повільну дію (ввести текст, відкрити список), зупинити запис;
  2. для кожного коміту (застосування змін у DOM) - діаграма flamegraph: які компоненти рендерились і скільки часу зайняли;
  3. Ranked - компоненти, відсортовані за часом рендеру;
  4. клік на компонент - скільки разів і чому він рендерився.

«Why did this render?» - у налаштуваннях Profiler увімкнути «Record why each component rendered»: DevTools покаже причину - змінився стан, змінились певні props, змінився контекст, перерендерився батько.

На що дивитися:

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

Значок React Compiler: компоненти, оптимізовані компілятором, позначені в DevTools міткою «Memo ✨» - видно, де компілятор спрацював, а де ні.

Важливо:

  • вимірюйте production-збірку: режим розробки повільніший (додаткові перевірки, StrictMode) - час у ньому завищений. Для профілювання production потрібна спеціальна збірка з увімкненим профілюванням;
  • вкладка Performance браузера доповнює React DevTools: показує, що поза React (мережа, розкладка, стилі) займає час. У нових версіях React додає в неї власні доріжки (React Performance tracks) з етапами рендеру;
  • спершу виміряти, потім оптимізувати - інтуїція щодо того, що повільне, часто помиляється.

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

React Compiler (стабільна версія 1.0) - інструмент етапу збирання, що автоматично додає мемоізацію в компоненти й хуки. Він аналізує код і кешує JSX, обчислення й функції так, щоб при повторному рендері перераховувалось лише те, що залежить від змінених даних.

До компілятора:

const Products = memo(function Products({ items, onSelect }) {
  const sorted = useMemo(() => [...items].sort(byPrice), [items]);
  const handleClick = useCallback((item) => onSelect(item.id), [onSelect]);
  return sorted.map((item) => <Item key={item.id} onClick={() => handleClick(item)} />);
});

З компілятором - той самий код без memo, useMemo, useCallback. Компілятор мемоізує навіть те, що вручну зробити складно: стрілкову функцію () => handleClick(item) у циклі, яка з ручним useCallback все одно створювалась би щоразу й ламала б memo дочірнього компонента.

На чому фокусується:

  • пропуск каскадних рендерів: якщо props дочірнього компонента не змінилися, він не рендериться;
  • пропуск дорогих обчислень у компонентах і хуках.

Компілятор не мемоізує звичайні функції поза компонентами й хуками і не кешує результати між різними екземплярами компонента.

Правила React, на які він покладається:

  • рендер чистий: компонент для тих самих props і стану повертає той самий результат, без побічних ефектів;
  • props і стан незмінні: не мутувати отримані об'єкти й масиви;
  • значення з хуків незмінні, ref.current не читається під час рендеру;
  • правила хуків (без умовних викликів).

Код, що порушує правила, компілятор пропускає (залишає без оптимізації), а не ламає. Порушення показує eslint-plugin-react-hooks.

Підключення: Babel-плагін babel-plugin-react-compiler (у Vite - через reactCompilerPreset плагіна React), підтримка в Next.js, Expo. Працює з React 17+ (для 17-18 - пакет react-compiler-runtime).

Що змінюється для розробника:

  • нові компоненти пишуться без ручної мемоізації;
  • існуючі useMemo/useCallback можна лишити - компілятор їх враховує; прибирати варто обережно, бо деякі з них впливають на залежності ефектів;
  • структурні оптимізації (віртуалізація, розділення коду, опускання стану) компілятор не замінює.

Докладніше в документації: React Compiler: вступ

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

Що варто пам'ятати: контекст добре підходить для рідко змінюваних даних - тема, мова, поточний користувач, налаштування. Проблеми з продуктивністю зазвичай починаються, коли в контекст кладуть швидко змінюваний стан.

Докладніше в документації: useContext: оптимізація рендерів

Відрендерити 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 лишається повним. Простий виграш для довгих сторінок з помірною кількістю елементів.

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

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

<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 і причинами рендерів.

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

Увімкнути компілятор на великому існуючому проєкті одним кроком ризиковано: код, що порушує правила React, але «випадково працював», може поводитися інакше після мемоізації. Документація радить поступове впровадження.

Три способи обмежити область дії:

1. За каталогами - через overrides Babel:

// babel.config.js
module.exports = {
  overrides: [
    { test: './src/features/checkout/**/*.{js,jsx,ts,tsx}', plugins: ['babel-plugin-react-compiler'] },
  ],
};

2. Режим анотацій - компілюються лише функції з директивою:

['babel-plugin-react-compiler', { compilationMode: 'annotation' }]
function ProductGrid({ products }) {
  'use memo';   // лише цей компонент оптимізується
  // ...
}

3. Runtime-гейтинг (gating) - компілятор генерує обидві версії, і вибір між ними робить прапорець функцій: можна порівняти метрики на частині користувачів.

Виключення компонента - "use no memo":

function LegacyDataGrid(props) {
  'use no memo';   // TODO: прибрати після виправлення мутації props у сортуванні (JIRA-123)
  // ...
}

Директиви - тимчасові аварійні виходи: документація радить пояснювати причину коментарем і планувати їх видалення.

Режими компіляції: infer (за замовчуванням - компілятор сам визначає компоненти й хуки за іменуванням), annotation, all; директиви перевизначають рішення режиму.

Типові причини проблем після ввімкнення:

  • мутація props чи стану - раніше рендер «підхоплював» зміну, тепер мемоізований результат лишається старим;
  • читання ref.current під час рендеру;
  • бібліотеки з власним механізмом відстеження змін (інкрементальні сховища, деякі бібліотеки форм і таблиць) - правило incompatible-library попереджає про відомі випадки;
  • ефекти, що залежали від нестабільних залежностей і раніше спрацьовували частіше.

Порядок дій:

  1. увімкнути правила компілятора в eslint-plugin-react-hooks і виправити порушення;
  2. ввімкнути компілятор на частині коду з хорошими тестами;
  3. перевірити в DevTools, які компоненти оптимізовано (мітка «Memo ✨»);
  4. порівняти метрики (INP, час рендеру), розширювати область.

Існуючі useMemo/useCallback не обов'язково прибирати одразу - компілятор їх враховує, а правило preserve-manual-memoization попередить, якщо не зможе зберегти ручну мемоізацію.

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

React вирішує, чи зберегти компонент між рендерами, за двома ознаками: позиція в дереві і тип (функція-компонент). Якщо на тій самій позиції опинився інший тип - старий компонент знищується разом зі станом і DOM, новий створюється з нуля.

Типова помилка:

function VacancyPage({ vacancy }) {
  const [tab, setTab] = useState('description');

  // нова функція на КОЖНОМУ рендері VacancyPage
  function ApplyForm() {
    const [email, setEmail] = useState('');
    return <input value={email} onChange={(e) => setEmail(e.target.value)} />;
  }

  return (
    <>
      <Tabs value={tab} onChange={setTab} />
      <ApplyForm />
    </>
  );
}

Кожен рендер VacancyPage створює нову функцію ApplyForm - для React це новий тип компонента. Наслідки:

  • стан скидається: введений email зникає при перемиканні вкладки;
  • DOM перестворюється - втрачається фокус, позиція курсору, анімації;
  • дорожче: монтування з нуля замість оновлення.

Рішення: оголошувати компоненти на верхньому рівні модуля і передавати дані через props. Правило static-components з eslint-plugin-react-hooks ловить цю помилку автоматично.

Та сама механіка свідомо - key для скидання стану:

<ProfileForm key={userId} user={user} />

Зміна key - новий компонент: форма скидається при переході до іншого користувача. Це краще, ніж синхронізувати стан з props в ефекті.

Але key має ціну. Перемонтування - це знищення й створення всього піддерева: ефекти очищення й запуску, нові запити, втрата стану в усіх нащадках. Зміна key на великому дереві без потреби (наприклад, key={Math.random()} чи key={JSON.stringify(filters)} на всій сторінці) - джерело повільності й «миготіння».

Інші випадки втрати стану через позицію:

  • умовний рендер, що змінює структуру: {isEditing ? <Form /> : <Preview />} на тій самій позиції - стан форми зникає при перемиканні. Якщо стан треба зберегти - тримати його вище чи ховати компонент CSS;
  • різні обгортки: <div><Counter /></div> проти <section><Counter /></section> - інший тип батька, лічильник скидається;
  • індекс у key списку - при видаленні першого елемента стан «переїжджає» на сусідні.

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

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

При серверному рендері (Next.js, Inertia SSR, React Router) сторінка приходить готовим HTML і швидко з'являється. Але інтерактивною вона стає лише після гідратації: браузер завантажує JavaScript, React заново виконує всі компоненти й «прив'язує» обробники подій до існуючого HTML.

import { hydrateRoot } from 'react-dom/client';

hydrateRoot(document.getElementById('root')!, <App />);

Чому це дорого:

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

Як прискорити:

1. Server Components (у фреймворках, що їх підтримують) - компоненти, які виконуються лише на сервері. Їхній код не потрапляє в бандл і не гідратується. Клієнтськими ('use client') лишаються лише інтерактивні частини.

2. Вибіркова гідратація через Suspense:

<Layout>
  <Header />
  <Suspense fallback={<CommentsSkeleton />}>
    <Comments />
  </Suspense>
</Layout>

Межі Suspense дають React змогу гідратувати частини незалежно: з потоковим рендером (renderToReadableStream/renderToPipeableStream) вміст надходить порціями, а при кліку на ще не гідратовану частину React пріоритетно гідратує саме її.

3. Менше JavaScript на сторінці: розділення коду, ліниве завантаження компонентів нижче першого екрана, легші бібліотеки.

4. Розбіжності гідратації (hydration mismatch) - HTML з сервера не збігається з першим клієнтським рендером (дата й час, Math.random(), window у рендері, розширення браузера змінили DOM). React 19 показує зрозумілу різницю в консолі, але виправлення - перерендер частини дерева на клієнті, тобто додаткова робота. Для неминучих відмінностей (час) - suppressHydrationWarning точково, для залежного від браузера - рендер після монтування.

5. Не гідратувати те, що не інтерактивне. Якщо сторінка переважно статична, «острівці» інтерактивності (окремі createRoot для віджетів) чи Server Components дешевші, ніж гідратація всього документа.

Метрики: Total Blocking Time і INP у Lighthouse/вебвіталсах, вкладка Performance - довгі задачі під час гідратації.

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

Повільна сторінка часто повільна не через рендер, а через водоспад завантажень: компонент рендериться - лише тоді дізнається, що потрібен шрифт, скрипт чи дані - запитує - чекає. React 19 додав API, щоб компонент міг оголосити ресурс заздалегідь.

Функції з react-dom:

import { preload, preinit, preconnect, prefetchDNS } from 'react-dom';

function ProductPage({ product }) {
  preconnect('https://cdn.example.com');                         // відкрити з'єднання
  preload(product.heroImage, { as: 'image', fetchPriority: 'high' });   // завантажити, не виконувати
  preinit('https://maps.example.com/sdk.js', { as: 'script' });   // завантажити й виконати
  preload('/fonts/Inter.woff2', { as: 'font', type: 'font/woff2', crossOrigin: 'anonymous' });

  return <Gallery images={product.images} />;
}
  • prefetchDNS - лише розв'язати домен;
  • preconnect - встановити з'єднання (DNS + TLS);
  • preload - завантажити ресурс (зображення, шрифт, стиль, скрипт), щоб він був у кеші, коли знадобиться;
  • preinit - завантажити й застосувати скрипт чи таблицю стилів;
  • preloadModule / preinitModule - те саме для ES-модулів.

Як це працює: виклики можна робити прямо під час рендеру чи в обробниках подій. При серверному рендері React виводить відповідні <link rel="preload"> у <head> якомога раніше в потоці HTML, а повторні виклики для того самого ресурсу дедуплікуються.

Метадані й стилі прямо в компонентах:

function Article({ post }) {
  return (
    <article>
      <title>{post.title}</title>
      <meta name="description" content={post.excerpt} />
      <link rel="stylesheet" href="/css/article.css" precedence="default" />
      ...
    </article>
  );
}

React 19 піднімає <title>, <meta>, <link> у <head> документа. Таблиці стилів з precedence завантажуються до показу вмісту, що від них залежить (без «миготіння» нестилізованого вмісту), і вставляються в правильному порядку.

Практичні застосування:

  • зображення першого екрана (LCP) - preload з високим пріоритетом;
  • попереднє завантаження при наведенні: onMouseEnter={() => preload(nextPageImage, { as: 'image' })};
  • сторонні SDK (карти, оплата) - preconnect заздалегідь, preinit лише на сторінках, де потрібні.

Обережно: зайвий preload конкурує за пропускну здатність з дійсно критичними ресурсами. Попередньо завантажувати варто те, що точно знадобиться найближчим часом.

Докладніше в документації: API попереднього завантаження ресурсів