Patterns

Multi-Tab Detail Pages

Pattern for implementing detail/edit pages with multiple tabs, centralized store management, and URL-persisted tab state.

Architecture Overview

  • URL-based tab state - Persists in URL, survives refresh
  • Centralized store - Main entity store passed through component tree
  • Lazy-loaded tab data - Tabs query their own related data
  • Loading gates - Handled at page level, not in tabs

Component Structure

page.tsx (Client Component)
├── Creates main store
├── Queries store on route param change
├── Handles loading/error states
└── Renders PageShell + DetailPage

page-content.tsx (Layout)
├── Receives store + row as props
├── Creates related stores
├── Manages tab state (URL-synced)
└── Renders tabs

tabs/*.tsx (Tab Components)
├── Receive store + row as props
├── Use store for mutations
└── Render tab-specific UI

Page with Store and Loading

// page.tsx
'use client';

import { use, useEffect } from 'react';
import { PageShell } from '@wayvo-ai/core/ui';
import { useIsStoreLoading, useStoreError, useCurrentRow } from '@wayvo-ai/core/ui';
import { Loader2 } from 'lucide-react';
import type { DBRow } from '@wayvo-ai/core/common';
import type { Entity } from '@/lib/common/ds/types/module/Entity';
import { useEntityStore } from './hooks/use-store';
import { DetailPage } from './page-content';

interface PageProps {
  params: Promise<{ id: string }>;
}

export default function Page({ params }: PageProps) {
  const store = useEntityStore();
  const { id } = use(params);
  
  useEffect(() => {
    store.executeQuery({
      query: { match: { id } },
      force: true,
    });
  }, [id, store]);
  
  const isLoading = useIsStoreLoading(store);
  const hasError = useStoreError(store);
  const row = useCurrentRow(store) as DBRow<Entity>;

  return (
    <PageShell title={`Entity: ${id}`} enableShareUrl>
      {isLoading || row?.id !== id ? (
        <div className="flex h-full items-center justify-center">
          <Loader2 className="size-10 animate-spin" />
        </div>
      ) : hasError ? (
        <div className="flex h-full items-center justify-center">
          <p className="text-destructive">Failed to load. Please try again.</p>
        </div>
      ) : !row ? (
        <div className="flex h-full items-center justify-center">
          <p>Entity not found</p>
        </div>
      ) : (
        <DetailPage entity={row} entityStore={store} />
      )}
    </PageShell>
  );
}

Tab State Management

// page-content.tsx
'use client';

import { useSearchParams, useRouter } from 'next/navigation';
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
import type { Store } from '@wayvo-ai/core/common';
import type { Entity } from '@/lib/common/ds/types/module/Entity';
import { OverviewTab } from './tabs/overview';
import { DetailsTab } from './tabs/details';

export function DetailPage({ entity, entityStore }: { entity: Entity; entityStore: Store<Entity> }) {
  const router = useRouter();
  const searchParams = useSearchParams();
  const activeTab = searchParams.get('tab') || 'overview';

  const handleTabChange = (value: string) => {
    const params = new URLSearchParams(searchParams.toString());
    params.set('tab', value);
    router.push(`?${params.toString()}`);
  };

  return (
    <Tabs value={activeTab} onValueChange={handleTabChange}>
      <TabsList>
        <TabsTrigger value="overview">Overview</TabsTrigger>
        <TabsTrigger value="details">Details</TabsTrigger>
      </TabsList>
      <TabsContent value="overview">
        <OverviewTab entity={entity} entityStore={entityStore} />
      </TabsContent>
      <TabsContent value="details">
        <DetailsTab entity={entity} entityStore={entityStore} />
      </TabsContent>
    </Tabs>
  );
}

Tab Components

// tabs/overview.tsx
'use client';

import type { Store } from '@wayvo-ai/core/common';
import type { Entity } from '@/lib/common/ds/types/module/Entity';
import { TextInput } from '@wayvo-ai/core/ui';
import { useCurrentRowSync } from '@wayvo-ai/core/ui';

export function OverviewTab({ entity, entityStore }: { entity: Entity; entityStore: Store<Entity> }) {
  const row = useCurrentRowSync(entityStore);

  return (
    <div className="grid gap-4 p-4">
      <TextInput
        label="Name"
        value={row?.name || ''}
        onChange={(value) => entityStore.setValue('name', value)}
      />
    </div>
  );
}

Key Principles

  1. Centralized store - Main entity store passed to all tabs
  2. URL state - Tab state in URL query params
  3. Loading at page level - Handle loading/error in page.tsx
  4. Lazy tab data - Each tab queries its own related data
  5. Shared mutations - All tabs use the same store for updates

Next Steps

Previous
CRUD Operations