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