The browser tab's label is document.title, and single-page apps have to manage it by hand — there's no <title> re-render on navigation the way a server-rendered page gets. useDocumentTitle is the small hook that owns it: point a component at a title string and it keeps the tab in sync, then puts the old title back when the component leaves. Think "unread count in the tab" or a route that shows the current page name.
Implement useDocumentTitle(title). While the component is mounted, document.title should equal title (re-applying whenever title changes). When the component unmounts, restore whatever the title was before the hook first ran.
function useDocumentTitle(title) {
// side-effect only; returns nothing.
}
function InboxPage({ unread }) {
useDocumentTitle(unread ? `(${unread}) Inbox` : 'Inbox');
// tab reads "(3) Inbox"; navigating away restores the prior title
}
useDocumentTitle('Checkout'); // tab is "Checkout"
// ...component unmounts -> tab goes back to whatever it said before
title, so a new title argument re-applies.document.title on the first run (a ref set on mount), not on every change. Otherwise unmount restores an intermediate title instead of the original.You'll set document.title in an effect that re-runs when the title changes, and stash the original title in a ref so unmount can put it back.
document.title is a single global string — the browser-tab label. In a single-page app nothing resets it for you when you navigate, so a component that wants the tab to say "Checkout" has to write it, and then clean up after itself when it leaves, or the next page inherits a stale title. useDocumentTitle encapsulates that write-and-restore dance: set the title while mounted, and return the tab to its prior state on unmount.
It's a borrow. On mount you remember the current title (so you can give it back), then you overwrite it. While mounted, any change to your title argument re-applies. On unmount, you restore what you borrowed. The subtle part is when you snapshot the original: it has to be once, on the first render, not on every title change.
The obvious version reads the previous title inside the same effect that sets the new one:
function useDocumentTitleNaive(title) {
useEffect(() => {
const prev = document.title; // captured on EVERY run
document.title = title;
return () => {
document.title = prev; // restores an intermediate title
};
}, [title]);
}
Because the effect re-runs on every title change, prev is captured after the previous title was already applied. Go Original -> "A" -> "B": when the "B" effect runs, prev is "A", not "Original". Unmount then restores "A". The snapshot of the true original has to happen exactly once.
const { useEffect, useRef } = require('react');
function useDocumentTitle(title) {
// Capture the title from BEFORE the hook, exactly once.
const prevTitle = useRef(null);
if (prevTitle.current === null) {
prevTitle.current = document.title;
}
// Apply the title whenever it changes.
useEffect(() => {
document.title = title;
}, [title]);
// Restore the original title on unmount only ([] deps).
useEffect(() => {
return () => {
document.title = prevTitle.current;
};
}, []);
}
module.exports = { useDocumentTitle };
Two effects, split by responsibility. The prevTitle ref is seeded on the very first render — the === null guard makes that assignment run once, before any title has been overwritten, so it holds the true original. The first effect (deps [title]) keeps the tab in sync as the argument changes. The second effect (deps []) has no setup and a cleanup that runs only on unmount, restoring the borrowed title. Splitting them is what fixes the naive bug: the restore closure reads a value snapshotted once, not one re-captured on every change.
Start with document.title === 'Original', mount useDocumentTitle('Inbox'), then the prop changes to '(3) Inbox', then the component unmounts:
prevTitle.current is null, so the guard sets it to 'Original'. Effects haven't run yet.[title] effect sets document.title = 'Inbox'. The [] effect registers its cleanup.'(3) Inbox' — the [title] effect re-runs, setting document.title = '(3) Inbox'. prevTitle.current is untouched (still 'Original').[] effect's cleanup runs: document.title = prevTitle.current = 'Original'. The tab is back to where it started, no matter how many times the title changed in between.[]-deps effect for the unmount-only restore.useState(document.title) vs a guard — both work; the point is once. Reading document.title in the render body without a guard would re-read every render.document is undefined on the server; in a real app guard the reads, though React effects only run in the browser so the effect bodies are already safe.restoreOnUnmount option — libraries expose a flag; some pages want their title to persist after unmount (e.g. a wizard's final step), so restore becomes opt-out.useDocumentTitle(title, { template: '%s · UIReady' }) pattern centralizes the "page · brand" suffix instead of repeating it at every call site.react-helmet) resolves who owns the tab.Keep practising the same patterns with a nearby challenge.
No submissions yet
Share your approach and start the discussion.
The browser tab's label is document.title, and single-page apps have to manage it by hand — there's no <title> re-render on navigation the way a server-rendered page gets. useDocumentTitle is the small hook that owns it: point a component at a title string and it keeps the tab in sync, then puts the old title back when the component leaves. Think "unread count in the tab" or a route that shows the current page name.
Implement useDocumentTitle(title). While the component is mounted, document.title should equal title (re-applying whenever title changes). When the component unmounts, restore whatever the title was before the hook first ran.
function useDocumentTitle(title) {
// side-effect only; returns nothing.
}
function InboxPage({ unread }) {
useDocumentTitle(unread ? `(${unread}) Inbox` : 'Inbox');
// tab reads "(3) Inbox"; navigating away restores the prior title
}
useDocumentTitle('Checkout'); // tab is "Checkout"
// ...component unmounts -> tab goes back to whatever it said before
title, so a new title argument re-applies.document.title on the first run (a ref set on mount), not on every change. Otherwise unmount restores an intermediate title instead of the original.You'll set document.title in an effect that re-runs when the title changes, and stash the original title in a ref so unmount can put it back.
document.title is a single global string — the browser-tab label. In a single-page app nothing resets it for you when you navigate, so a component that wants the tab to say "Checkout" has to write it, and then clean up after itself when it leaves, or the next page inherits a stale title. useDocumentTitle encapsulates that write-and-restore dance: set the title while mounted, and return the tab to its prior state on unmount.
It's a borrow. On mount you remember the current title (so you can give it back), then you overwrite it. While mounted, any change to your title argument re-applies. On unmount, you restore what you borrowed. The subtle part is when you snapshot the original: it has to be once, on the first render, not on every title change.
The obvious version reads the previous title inside the same effect that sets the new one:
function useDocumentTitleNaive(title) {
useEffect(() => {
const prev = document.title; // captured on EVERY run
document.title = title;
return () => {
document.title = prev; // restores an intermediate title
};
}, [title]);
}
Because the effect re-runs on every title change, prev is captured after the previous title was already applied. Go Original -> "A" -> "B": when the "B" effect runs, prev is "A", not "Original". Unmount then restores "A". The snapshot of the true original has to happen exactly once.
const { useEffect, useRef } = require('react');
function useDocumentTitle(title) {
// Capture the title from BEFORE the hook, exactly once.
const prevTitle = useRef(null);
if (prevTitle.current === null) {
prevTitle.current = document.title;
}
// Apply the title whenever it changes.
useEffect(() => {
document.title = title;
}, [title]);
// Restore the original title on unmount only ([] deps).
useEffect(() => {
return () => {
document.title = prevTitle.current;
};
}, []);
}
module.exports = { useDocumentTitle };
Two effects, split by responsibility. The prevTitle ref is seeded on the very first render — the === null guard makes that assignment run once, before any title has been overwritten, so it holds the true original. The first effect (deps [title]) keeps the tab in sync as the argument changes. The second effect (deps []) has no setup and a cleanup that runs only on unmount, restoring the borrowed title. Splitting them is what fixes the naive bug: the restore closure reads a value snapshotted once, not one re-captured on every change.
Start with document.title === 'Original', mount useDocumentTitle('Inbox'), then the prop changes to '(3) Inbox', then the component unmounts:
prevTitle.current is null, so the guard sets it to 'Original'. Effects haven't run yet.[title] effect sets document.title = 'Inbox'. The [] effect registers its cleanup.'(3) Inbox' — the [title] effect re-runs, setting document.title = '(3) Inbox'. prevTitle.current is untouched (still 'Original').[] effect's cleanup runs: document.title = prevTitle.current = 'Original'. The tab is back to where it started, no matter how many times the title changed in between.[]-deps effect for the unmount-only restore.useState(document.title) vs a guard — both work; the point is once. Reading document.title in the render body without a guard would re-read every render.document is undefined on the server; in a real app guard the reads, though React effects only run in the browser so the effect bodies are already safe.restoreOnUnmount option — libraries expose a flag; some pages want their title to persist after unmount (e.g. a wizard's final step), so restore becomes opt-out.useDocumentTitle(title, { template: '%s · UIReady' }) pattern centralizes the "page · brand" suffix instead of repeating it at every call site.react-helmet) resolves who owns the tab.Keep practising the same patterns with a nearby challenge.
No submissions yet
Share your approach and start the discussion.