ReactHoisting title, meta and link — and watching two titles both land
import { useState, useEffect } from "react";
function ArticleMeta({ article }) {
return (
<>
<title>{article.title}</title>
<meta name="description" content={article.summary} />
<link rel="canonical" href={article.url} />
</>
);
}
function WidgetMeta() {
return <title>Widget title — the second one</title>;
}
const ARTICLES = [
{ id: 1, title: "Hoisting metadata in React 19", summary: "How title, meta and link tags move to the head.", url: "https://example.com/a" },
{ id: 2, title: "A completely different article", summary: "Switching articles changes the head live.", url: "https://example.com/b" },
];
export default function App() {
const [id, setId] = useState(1);
const [showSecond, setShowSecond] = useState(false);
const [head, setHead] = useState(null);
const article = ARTICLES.find((a) => a.id === id);
useEffect(() => {
const t = setTimeout(() => {
setHead({
title: document.title,
titleCount: document.head.querySelectorAll("title").length,
titles: [...document.head.querySelectorAll("title")].map((n) => n.textContent),
description: document.head.querySelector('meta[name="description"]')?.content ?? "(none)",
canonical: document.head.querySelector('link[rel="canonical"]')?.href ?? "(none)",
});
}, 30);
return () => clearTimeout(t);
}, [id, showSecond]);
return (
<div style={{ padding: 24, fontFamily: "system-ui", lineHeight: 1.6, maxWidth: 560 }}>
<div style={{ display: "flex", gap: 8, marginBottom: 12, flexWrap: "wrap" }}>
{ARTICLES.map((a) => (
<button key={a.id} onClick={() => setId(a.id)} style={{ fontWeight: a.id === id ? "bold" : "normal" }}>
article {a.id}
</button>
))}
<button onClick={() => setShowSecond((s) => !s)}>
{showSecond ? "remove" : "add"} a SECOND component with a title
</button>
</div>
<article style={{ border: "1px solid #ddd", borderRadius: 8, padding: 12 }}>
{/* These three tags are written HERE and end up in document.head. */}
<ArticleMeta article={article} />
{showSecond && <WidgetMeta />}
<h3 style={{ margin: "0 0 4px" }}>{article.title}</h3>
<p style={{ fontSize: 13, color: "#555", margin: 0 }}>{article.summary}</p>
</article>
<pre style={{ background: "#f6f6f8", padding: 10, borderRadius: 8, fontSize: 12, marginTop: 12, minHeight: 120, whiteSpace: "pre-wrap" }}>
{head ? [
"document.title: " + JSON.stringify(head.title),
"<title> in head: " + head.titleCount,
"their contents: " + JSON.stringify(head.titles),
"description: " + JSON.stringify(head.description),
"canonical: " + JSON.stringify(head.canonical),
].join("\n") : "reading the head…"}
</pre>
<p style={{ fontSize: 13, color: "#666" }}>
Switch articles: the head updates live, and none of the tags render
inside the bordered box. Now add the second component — you get{" "}
<strong>two</strong> title elements, not one. React hoists; it does not
decide which should win. That is exactly the job a framework metadata
API is still doing.
</p>
</div>
);
}