Протягом багатьох років стандартним рішенням для керування вмістом тегу <head> в екосистемі React були бібліотеки react-helmet та її спадкоємиця react-helmet-async. Проте в міру еволюції архітектури React ці інструменти перетворилися на джерело критичних проблем стабільності та продуктивності.
Оригінальний react-helmet (створений NFL) використовував синглтон-модель із прямими синхронними мутаціями глобального об'єкта document.head через сайд-ефекти бібліотеки react-side-effect. З появою React Concurrent Mode та асинхронного рендерингу синхронні мутації DOM поза життєвим циклом Fiber почали спричиняти непереборні стани гонитви (race conditions):
react-helmet мутував DOM реального документа ще до того, як гілка була зафіксована (committed). Якщо рендер переривався, у <head> залишалися теги від нездійсненого переходу.Бібліотека react-helmet-async вирішила проблему ізоляції серверних запитів за допомогою HelmetProvider. Проте фундаментальний архітектурний недолік залишився: маніпуляція тегами <head> здійснювалася через мікрозадачі та сайд-ефекти (requestAnimationFrame на клієнті), додаючи в розмітку службові маркерні атрибути data-rh="true".
З виходом React 19 необхідність у сторонніх провайдерах зникла. Повноцінна міграція на нативний Document Metadata Hoisting усуває проміжні рівні абстракції, зменшує розмір клієнтського бандла (мінус 15–25 КБ gzip) і гарантує детермінований стан документа.
+-------------------------------------------------------------------------+
| АРХІТЕКТУРА МІГРАЦІЇ: ВІД HELMETPROVIDER ДО НАНО-ПРИМІТИВІВ REACT 19|
+-------------------------------------------------------------------------+
[ СТАРА МОДЕЛЬ: react-helmet-async ]
+-----------------------------------------------------------------------+
| <HelmetProvider context={helmetContext}> |
| <App> |
| <Helmet> |
| <title>My Page</title> ---> Сайд-ефект у requestAnimationFrame |
| </Helmet> Мутація: document.head.appendChild |
| </App> Розмітка: <title data-rh="true"> |
+-----------------------------------------------------------------------+
|
v МІГРАЦІЯ НА REACT 19
[ НОВА МОДЕЛЬ: Native React 19 Hoisting ]
+-----------------------------------------------------------------------+
| <App> |
| <title>My Page</title> ---> Fiber Reconciliation Engine |
| <meta name="desc" ... /> Вбудований HostResourceElement |
| </App> Прямий комміт у <head> документа |
| Розмітка: <title>My Page</title> |
+-----------------------------------------------------------------------+Міграція великого комерційного застосунку з сотнями маршрутів не може бути виконана одномоментною заміною файлів без ризику регресій. Інженерна стратегія спирається на 3 етапи:
Фаза очищення від легасі-атрибутів (data-rh):
react-helmet-async позначає створені теги атрибутом data-rh="true".<title>, а в документі залишився старий тег <title data-rh="true">, браузер може відобразити перший знайдений елемент у <head>, ігноруючи нативні оновлення.Створення ізоляційного адаптера зворотної сумісності (Compatibility Shim):
<Helmet>, інтерфейс якого повністю ідентичний старому API, але під капотом він просто повертає нативний JSX без сторонніх залежностей.Ліквідація інфраструктурної обв'язки:
<HelmetProvider>, контекстів helmetData, серверних викликів helmetContext.helmet в SSR-конфігураціях та видалення пакетів із package.json.react-helmet-async виконує парсинг і мутацію DOM у головному потоці (Main Thread) через ланцюжки промісів після кожного клієнтського переходу, збільшуючи час блокування потоку. Нативний хойстинг виконує комміт синхронно із загальним рендер-батчем React.helmetContext.helmet.title.toString() і ручної рядкової інтерполяції HTML при формуванні відповіді на сервері.Розглянемо процес покрокової модернізації кодової бази.
Адаптер емулює компонент Helmet із react-helmet-async, перенаправляючи дочірні теги безпосередньо в рушій реконсиляції React 19:
import React from 'react';
export interface HelmetShimProps {
children?: React.ReactNode;
title?: string;
defaultTitle?: string;
titleTemplate?: string;
}
/**
* Адаптер зворотної сумісності для плавної міграції з react-helmet-async.
* Розгортає дочірні елементи в нативні теги React 19.
*/
export const Helmet: React.FC<HelmetShimProps> = ({
children,
title,
defaultTitle,
titleTemplate,
}) => {
// Обчислюємо заголовок з урахуванням шаблону, якщо він заданий
const resolvedTitle = React.useMemo(() => {
const rawTitle = title || defaultTitle;
if (!rawTitle) return null;
if (titleTemplate && title) {
return titleTemplate.replace('%s', title);
}
return rawTitle;
}, [title, defaultTitle, titleTemplate]);
return (
<>
{resolvedTitle && <title>{resolvedTitle}</title>}
{children}
</>
);
};
export default Helmet;Під час гідратації або оновлення SPA теги, створені старим кодом шолома, мають бути безпечно видалені, щоб уникнути конфліктів дедуплікації з React 19:
/**
* Видаляє з document.head залишкові DOM-вузли, створені react-helmet(-async).
* Викликається один раз перед монтуванням кореневого дерева React 19.
*/
export function purgeLegacyHelmetAttributes(): void {
if (typeof document === 'undefined') return;
const legacyElements = document.head.querySelectorAll('[data-rh="true"], [data-react-helmet="true"]');
legacyElements.forEach((node) => {
node.parentNode?.removeChild(node);
});
if (process.env.NODE_ENV === 'development' && legacyElements.length > 0) {
console.info(`[SEO Migration] Видалено ${legacyElements.length} застарілих DOM-елементів Helmet.`);
}
}import React from 'react';
import ReactDOM from 'react-dom/client';
import { purgeLegacyHelmetAttributes } from './utils/cleanup-legacy-helmet';
import { App } from './App';
// Очищаємо DOM від старих тегів до ініціалізації React 19
purgeLegacyHelmetAttributes();
const rootElement = document.getElementById('root');
if (!rootElement) {
throw new Error('Кореневий DOM-елемент #root не знайдено');
}
// React 19 createRoot: HelmetProvider більше не потрібен
ReactDOM.createRoot(rootElement).render(
<React.StrictMode>
<App />
</React.StrictMode>
);Порівняємо код до та після міграції в реальному компоненті сторінки:
import React from 'react';
interface ArticleData {
id: string;
slug: string;
title: string;
excerpt: string;
publishedAt: string;
}
interface BlogPostPageProps {
article: ArticleData;
}
export const BlogPostPage: React.FC<BlogPostPageProps> = ({ article }) => {
const canonicalUrl = `https://agenticlab.dev/blog/${article.slug}`;
return (
<article className="prose mx-auto py-8">
{/*
РЕЗУЛЬТАТ МІГРАЦІЇ:
Замість <Helmet><title>...</title></Helmet>
оголошуємо нативні теги безпосередньо в JSX.
React 19 виконає хойстинг у document.head автоматично.
*/}
<title>{`${article.title} | Fullstack Agentic Lab`}</title>
<meta name="description" content={article.excerpt} />
<meta name="author" content="Fullstack Agentic Lab Team" />
{/* OpenGraph специфікація */}
<meta property="og:title" content={article.title} />
<meta property="og:description" content={article.excerpt} />
<meta property="og:type" content="article" />
<meta property="og:url" content={canonicalUrl} />
{/* Канонічний URL */}
<link rel="canonical" href={canonicalUrl} />
<header>
<h1 className="text-3xl font-bold">{article.title}</h1>
<time dateTime={article.publishedAt}>{article.publishedAt}</time>
</header>
<div className="content mt-4">
<p>{article.excerpt}</p>
</div>
</article>
);
};Якщо залишити <HelmetProvider> у кореневому дереві після видалення react-helmet-async, TypeScript викине помилку компіляції про відсутність типів, а при використанні бандла без типів провайдер буде марно споживати пам'ять та ініціалізувати порожній React-контекст.
Рішення: Повністю видаліть імпорт та тег <HelmetProvider> із файлів App.tsx та main.tsx.
React 19 дозволяє хойстити лише валідні елементи заголовка: <title>, <meta>, <link>, <style>. Якщо всередині JSX помилково розмістити <div> або <p> без батьківської обгортки, React викине помилку валідації схеми DOM: Warning: In HTML, <div> cannot be a child of <head>.
Рішення: Суворо стежте за типами елементів та ізолюйте візуальну розмітку інтерфейсу від метатегів.
У react-helmet розробники часто використовували колбек onChangeClientState для надсилання аналітики (наприклад, події page_view у Google Tag Manager при зміні заголовка). У нативному React 19 цей колбек відсутній.
Рішення: Для аналітичних подій відстежуйте зміну маршруту на рівні роутера (useLocation / useNavigation) або використовуйте кастомний хук з useEffect.
У старому коді шолома для примусової дедуплікації іноді писали <meta key="desc" name="description" content="..." />. У React 19 проп key керує внутрішньою ідентифікацією елементів списків React, а дедуплікація в <head> здійснюється суворо за name та property.
Рішення: Не використовуйте key для керування дедуплікацією метатегів. Спирайтеся на нативні HTML-атрибути name та property.
Видалення react-helmet, react-helmet-async та їхніх @types із package.json. Перевірка залежностей через pnpm ls.
Створення легкого компонента зворотної сумісності в src/components/seo для уникнення зламу існуючих сторінок.
Видалення провайдера контексту з main.tsx та впровадження утиліти purgeLegacyHelmetAttributes для видалення data-rh.
Заміна імпортів <Helmet> на пряме оголошення <title>, <meta> і <link> у компонентах маршрутів.