Page State Management
Sync page title and breadcrumbs from deep components up to the root layout header.
What Problem This Solves
Page components need to declare their title and breadcrumbs, but the header lives in the root layout. This pattern bridges that gap using a Zustand store.
Prerequisites
zustand
Files
use-page-layout.ts
ts
import { create } from "zustand";
type BreadcrumbItem = {
title: string;
href?: string;
};
type PageLayoutState = {
pageTitle: string;
breadCrumbItems: BreadcrumbItem[];
setPageMeta: (meta: { pageTitle?: string; breadCrumbItems?: BreadcrumbItem[] }) => void;
};
export const usePageLayout = create<PageLayoutState>((set) => ({
pageTitle: "",
breadCrumbItems: [],
setPageMeta: (meta) => set((state) => ({ ...state, ...meta })),
}));page-layout.tsx (Imperative — via useEffect)
tsx
import { useEffect } from "react";
import { usePageLayout } from "./use-page-layout";
type PageLayoutProps = {
children: React.ReactNode;
header: string;
breadCrumbItems?: Array<{ title: string; href?: string }>;
};
export function PageLayout({ children, header, breadCrumbItems }: PageLayoutProps) {
const setPageMeta = usePageLayout((s) => s.setPageMeta);
useEffect(() => {
setPageMeta({ pageTitle: header, breadCrumbItems });
// Optional: sync document title
document.title = header ? `${header} | MyApp` : "MyApp";
}, [header, breadCrumbItems, setPageMeta]);
return <>{children}</>;
}page-layout.tsx (Declarative — renders header inline)
tsx
import { Breadcrumb } from "antd";
import { usePageLayout } from "./use-page-layout";
type PageLayoutProps = {
children: React.ReactNode;
header: string;
breadCrumbItems?: Array<{ title: string; href?: string }>;
};
export function PageLayout({ children, header, breadCrumbItems }: PageLayoutProps) {
const setPageMeta = usePageLayout((s) => s.setPageMeta);
// Still update the store so the root layout knows
useEffect(() => {
setPageMeta({ pageTitle: header, breadCrumbItems });
}, [header, breadCrumbItems, setPageMeta]);
return (
<div className="space-y-4">
{breadCrumbItems && (
<Breadcrumb
items={breadCrumbItems.map((item) => ({
title: item.href ? <a href={item.href}>{item.title}</a> : item.title,
}))}
/>
)}
<h1 className="text-2xl font-bold">{header}</h1>
{children}
</div>
);
}Usage
tsx
import { PageLayout } from "./page-layout";
export function UserDetailPage() {
return (
<PageLayout
header="User Details"
breadCrumbItems={[
{ title: "Home", href: "/" },
{ title: "Users", href: "/users" },
{ title: "John Doe" },
]}
>
<UserProfile />
</PageLayout>
);
}Variations
| Approach | Pros | Cons |
|---|---|---|
Imperative (useEffect only) | Clean separation, layout controls rendering | Two sources of truth for header |
| Declarative (renders header) | Single source of truth, simpler mental model | Header rendered twice if root layout also shows it |