Skip to content

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 ​

ApproachProsCons
Imperative (useEffect only)Clean separation, layout controls renderingTwo sources of truth for header
Declarative (renders header)Single source of truth, simpler mental modelHeader rendered twice if root layout also shows it