f-ui
Design

List Page Statistics

Norms for Ant exclusive-area KPIs — page order, filter scope, backend summary contracts, TanStack Query wiring, and async states.

Use this page when placing summary metrics above a resource list. Ant Design’s data list exclusive area (独占区) holds statistics and complex inquire that do not fit the toolbar. In f-ui that region is the DataListShell stats slot — fill KPI content with Statistic / Statistic Group, and keep the list body on Table (or List / Card).

Examples

Filter Then Stats Then Table

Ant’s research-list stack is single-column: inquire, then exclusive-area KPIs, then the list. QueryList’s stats slot places Statistic Group between QueryFilter and the table card.

  1. Data filtering (QueryFilter)
  2. Data statistics (Statistic Group)
  3. Data list (table)
Orders
6
Total amount
$14,207.50
Unshipped
4
ORD-1001AcmeConfirmed2,499.00
ORD-1002GlobexShipped849.50
ORD-1003InitechConfirmed120.00
ORD-1004UmbrellaCancelled399.00
ORD-1005StarkShipped9,800.00
ORD-1006WayneConfirmed540.00
6 rows
Rows per page
"use client";

import { useMemo, useState } from "react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";

import { QueryList } from "@/components/f-ui/query-list/query-list";
import { Statistic } from "@/components/f-ui/statistic/statistic";
import { StatisticGroup } from "@/components/f-ui/statistic/statistic-group";

import {
  createDemoOrderAdapter,
  demoOrderSchema,
  filterScopedKpis,
  type DemoOrder,
} from "./demo-orders";

const adapter = createDemoOrderAdapter();

const queryClient = new QueryClient({
  defaultOptions: { queries: { retry: false, refetchOnWindowFocus: false } },
});

function StackDemoInner() {
  const [params, setParams] = useState<Record<string, unknown>>({});
  const kpis = useMemo(() => filterScopedKpis(params), [params]);

  return (
    <div className="space-y-3">
      <ol className="text-muted-foreground list-decimal space-y-0.5 pl-4 text-xs">
        <li>Data filtering (QueryFilter)</li>
        <li>Data statistics (Statistic Group)</li>
        <li>Data list (table)</li>
      </ol>
      <QueryList<DemoOrder>
        schema={demoOrderSchema}
        listCode="demo-list-page-statistics-stack"
        adapter={adapter}
        params={params}
        onParamsChange={(updates) =>
          setParams((prev) => ({ ...prev, ...updates }))
        }
        getRowId={(row) => row.id}
        recipe={{ filtering: true, selection: false, paginationMode: "offset" }}
        fillHeight={false}
        queryFilterCollapsedRows={1}
        queryFilterDefaultCollapsed={false}
        stats={
          <StatisticGroup>
            <Statistic variant="card" title="Orders" value={kpis.orderCount} />
            <Statistic
              variant="card"
              title="Total amount"
              value={kpis.totalAmount}
              precision={2}
              prefix="$"
            />
            <Statistic
              variant="card"
              title="Unshipped"
              value={kpis.unshippedCount}
            />
          </StatisticGroup>
        }
      />
    </div>
  );
}

/** Ant research-list stack: filter → stats → table via QueryList `stats`. */
export function ListPageStatisticsStackDemo() {
  return (
    <QueryClientProvider client={queryClient}>
      <StackDemoInner />
    </QueryClientProvider>
  );
}

Keep The Count Modest

Aim for about three to four visible KPIs (soft max six). Left side crams a mini dashboard into the exclusive area; right side stays scoped and links heavy analytics elsewhere.

Wrong

Orders
1,284
Total amount
$982,341.50
Shipped
910
Cancelled
42
Avg order
$765.84
Open claims
18
Returns
27
On hold
9
Net margin %
14.2%

Nine cards — analytics dashboard, not a list exclusive area

Right

Orders
1,284
Total amount
$982,341.50
Shipped
910
Cancelled
42

Four scoped KPIs — link out for trends and more metrics

import { Statistic } from "@/components/f-ui/statistic/statistic";
import { StatisticGroup } from "@/components/f-ui/statistic/statistic-group";
import { DesignCompare } from "@/demos/_design/design-compare";

const GOOD = [
  { title: "Orders", value: 1284 },
  { title: "Total amount", value: 982341.5, precision: 2, prefix: "$" as const },
  { title: "Shipped", value: 910 },
  { title: "Cancelled", value: 42 },
];

const OVERLOAD = [
  ...GOOD,
  { title: "Avg order", value: 765.84, precision: 2, prefix: "$" as const },
  { title: "Open claims", value: 18 },
  { title: "Returns", value: 27 },
  { title: "On hold", value: 9 },
  { title: "Net margin %", value: 14.2, precision: 1, suffix: "%" as const },
];

/** Soft max 3–6 KPIs vs exclusive-area overload (8+). */
export function ListPageStatisticsCountDemo() {
  return (
    <DesignCompare
      wrong={
        <div className="space-y-3">
          <StatisticGroup columns={3}>
            {OVERLOAD.map((kpi) => (
              <Statistic key={kpi.title} variant="card" {...kpi} />
            ))}
          </StatisticGroup>
          <p className="text-muted-foreground text-xs">
            Nine cards — analytics dashboard, not a list exclusive area
          </p>
        </div>
      }
      right={
        <div className="space-y-3">
          <StatisticGroup>
            {GOOD.map((kpi) => (
              <Statistic key={kpi.title} variant="card" {...kpi} />
            ))}
          </StatisticGroup>
          <p className="text-muted-foreground text-xs">
            Four scoped KPIs — link out for trends and more metrics
          </p>
        </div>
      }
    />
  );
}

Filter-Scoped KPIs

By default the summary query uses the same applied filters as the list. Change status or customer below and watch the KPI cards update with the filtered set.

Apply a status or customer filter — KPIs recompute over the same applied params as the list (not the current page rows alone).

Orders
6
Total amount
$14,207.50
Unshipped
4
ORD-1001AcmeConfirmed2,499.00
ORD-1002GlobexShipped849.50
ORD-1003InitechConfirmed120.00
ORD-1004UmbrellaCancelled399.00
ORD-1005StarkShipped9,800.00
ORD-1006WayneConfirmed540.00
6 rows
Rows per page
"use client";

import { useMemo, useState } from "react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";

import { QueryList } from "@/components/f-ui/query-list/query-list";
import { Statistic } from "@/components/f-ui/statistic/statistic";
import { StatisticGroup } from "@/components/f-ui/statistic/statistic-group";

import {
  createDemoOrderAdapter,
  demoOrderSchema,
  filterScopedKpis,
  type DemoOrder,
} from "./demo-orders";

const adapter = createDemoOrderAdapter();

const queryClient = new QueryClient({
  defaultOptions: { queries: { retry: false, refetchOnWindowFocus: false } },
});

function FilterScopedInner() {
  const [params, setParams] = useState<Record<string, unknown>>({});
  const kpis = useMemo(() => filterScopedKpis(params), [params]);

  return (
    <div className="space-y-3">
      <p className="text-muted-foreground text-xs">
        Apply a status or customer filter — KPIs recompute over the same applied
        params as the list (not the current page rows alone).
      </p>
      <QueryList<DemoOrder>
        schema={demoOrderSchema}
        listCode="demo-list-page-statistics-filter-scoped"
        adapter={adapter}
        params={params}
        onParamsChange={(updates) =>
          setParams((prev) => ({ ...prev, ...updates }))
        }
        getRowId={(row) => row.id}
        recipe={{ filtering: true, selection: false, paginationMode: "offset" }}
        fillHeight={false}
        queryFilterCollapsedRows={1}
        queryFilterDefaultCollapsed={false}
        stats={
          <StatisticGroup>
            <Statistic variant="card" title="Orders" value={kpis.orderCount} />
            <Statistic
              variant="card"
              title="Total amount"
              value={kpis.totalAmount}
              precision={2}
              prefix="$"
            />
            <Statistic
              variant="card"
              title="Unshipped"
              value={kpis.unshippedCount}
            />
          </StatisticGroup>
        }
      />
    </div>
  );
}

/** Filter-scoped summary: KPIs follow applied QueryFilter params. */
export function ListPageStatisticsFilterScopedDemo() {
  return (
    <QueryClientProvider client={queryClient}>
      <FilterScopedInner />
    </QueryClientProvider>
  );
}

Stats Region Async States

The host that loads KPIs owns Loading / Empty / Error for the stats band. Toggle the demo state — the table stays usable when summary is pending or fails; recovery stays inline in the stats region.

Stats state

Stats region owns Loading / Empty / Error. The table stays populated when KPIs are pending or fail.

Orders
6
Total amount
$14,207.50
Unshipped
4
ORD-1001AcmeConfirmed2,499.00
ORD-1002GlobexShipped849.50
ORD-1003InitechConfirmed120.00
ORD-1004UmbrellaCancelled399.00
ORD-1005StarkShipped9,800.00
ORD-1006WayneConfirmed540.00
6 rows
Rows per page
"use client";

import { useMemo, useState } from "react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";

import { Empty } from "@/components/f-ui/empty/empty";
import { QueryList } from "@/components/f-ui/query-list/query-list";
import { Result } from "@/components/f-ui/result/result";
import { Statistic } from "@/components/f-ui/statistic/statistic";
import { StatisticGroup } from "@/components/f-ui/statistic/statistic-group";
import { Button } from "@/components/ui/button";

import {
  createDemoOrderAdapter,
  demoOrderSchema,
  filterScopedKpis,
  type DemoOrder,
} from "./demo-orders";

type StatsView = "populated" | "loading" | "empty" | "error";

const VIEWS: { id: StatsView; label: string }[] = [
  { id: "populated", label: "Populated" },
  { id: "loading", label: "Loading" },
  { id: "empty", label: "Empty" },
  { id: "error", label: "Error" },
];

const adapter = createDemoOrderAdapter();

const queryClient = new QueryClient({
  defaultOptions: { queries: { retry: false, refetchOnWindowFocus: false } },
});

function StatsRegion({
  view,
  onRetry,
}: {
  view: StatsView;
  onRetry: () => void;
}) {
  const kpis = useMemo(() => filterScopedKpis({}), []);

  if (view === "loading") {
    return (
      <StatisticGroup>
        <Statistic variant="card" title="Orders" value={kpis.orderCount} loading />
        <Statistic
          variant="card"
          title="Total amount"
          value={kpis.totalAmount}
          precision={2}
          prefix="$"
          loading
        />
        <Statistic
          variant="card"
          title="Unshipped"
          value={kpis.unshippedCount}
          loading
        />
      </StatisticGroup>
    );
  }

  if (view === "error") {
    return (
      <div className="rounded-xl border bg-card">
        <Result
          size="region"
          status="error"
          title="Could not load summary"
          subTitle="The list below still shows current rows. Retry the KPI query only."
          extra={
            <Button type="button" variant="outline" onClick={onRetry}>
              Retry
            </Button>
          }
        />
      </div>
    );
  }

  if (view === "empty") {
    return (
      <div className="rounded-xl border bg-card">
        <Empty
          size="region"
          title="No headline metrics"
          description="This list has no exclusive-area KPIs configured. Omit the stats slot in product, or show short empty copy when zeros need explaining."
        />
      </div>
    );
  }

  return (
    <StatisticGroup>
      <Statistic variant="card" title="Orders" value={kpis.orderCount} />
      <Statistic
        variant="card"
        title="Total amount"
        value={kpis.totalAmount}
        precision={2}
        prefix="$"
      />
      <Statistic variant="card" title="Unshipped" value={kpis.unshippedCount} />
    </StatisticGroup>
  );
}

function AsyncDemoInner() {
  const [view, setView] = useState<StatsView>("populated");
  const [params, setParams] = useState<Record<string, unknown>>({});

  return (
    <div className="space-y-3">
      <div className="flex flex-wrap items-center gap-2">
        <p className="text-muted-foreground text-sm font-medium">Stats state</p>
        {VIEWS.map(({ id, label }) => (
          <Button
            key={id}
            type="button"
            variant={view === id ? "default" : "outline"}
            onClick={() => setView(id)}
          >
            {label}
          </Button>
        ))}
      </div>
      <p className="text-muted-foreground text-xs">
        Stats region owns Loading / Empty / Error. The table stays populated when
        KPIs are pending or fail.
      </p>
      <QueryList<DemoOrder>
        schema={demoOrderSchema}
        listCode="demo-list-page-statistics-async"
        adapter={adapter}
        params={params}
        onParamsChange={(updates) =>
          setParams((prev) => ({ ...prev, ...updates }))
        }
        getRowId={(row) => row.id}
        recipe={{ filtering: false, selection: false, paginationMode: "offset" }}
        fillHeight={false}
        stats={
          <StatsRegion view={view} onRetry={() => setView("populated")} />
        }
      />
    </div>
  );
}

/** Stats-region async triad — list body is not blanked for KPI failure. */
export function ListPageStatisticsAsyncDemo() {
  return (
    <QueryClientProvider client={queryClient}>
      <AsyncDemoInner />
    </QueryClientProvider>
  );
}

Create Belongs In The Page Header

Create (and list-wide Import / Export) stay on PageHeader.extra for a resource index. Left side parks Create in the exclusive area; right side keeps KPIs presentational.

Wrong

Orders

Summary

Orders
6
Total amount
$14,207.50
Unshipped
4
ORD-1001AcmeConfirmed2,499.00
ORD-1002GlobexShipped849.50
ORD-1003InitechConfirmed120.00
ORD-1004UmbrellaCancelled399.00
ORD-1005StarkShipped9,800.00
ORD-1006WayneConfirmed540.00
6 rows
Rows per page

Create parked in the exclusive-area band

Right

Orders

Orders
6
Total amount
$14,207.50
Unshipped
4
ORD-1001AcmeConfirmed2,499.00
ORD-1002GlobexShipped849.50
ORD-1003InitechConfirmed120.00
ORD-1004UmbrellaCancelled399.00
ORD-1005StarkShipped9,800.00
ORD-1006WayneConfirmed540.00
6 rows
Rows per page

Create stays in PageHeader.extra on a resource index

"use client";

import { useMemo, useState } from "react";
import { PlusIcon } from "lucide-react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";

import { PageHeader } from "@/components/f-ui/page/page-header";
import { QueryList } from "@/components/f-ui/query-list/query-list";
import { Statistic } from "@/components/f-ui/statistic/statistic";
import { StatisticGroup } from "@/components/f-ui/statistic/statistic-group";
import { Button } from "@/components/ui/button";
import { DesignCompare } from "@/demos/_design/design-compare";

import {
  createDemoOrderAdapter,
  demoOrderSchema,
  filterScopedKpis,
  type DemoOrder,
} from "./demo-orders";

const adapterWrong = createDemoOrderAdapter();
const adapterRight = createDemoOrderAdapter();

const queryClient = new QueryClient({
  defaultOptions: { queries: { retry: false, refetchOnWindowFocus: false } },
});

function KpiCards({ params }: { params: Record<string, unknown> }) {
  const kpis = useMemo(() => filterScopedKpis(params), [params]);
  return (
    <StatisticGroup>
      <Statistic variant="card" title="Orders" value={kpis.orderCount} />
      <Statistic
        variant="card"
        title="Total amount"
        value={kpis.totalAmount}
        precision={2}
        prefix="$"
      />
      <Statistic variant="card" title="Unshipped" value={kpis.unshippedCount} />
    </StatisticGroup>
  );
}

function WrongPanel() {
  const [params, setParams] = useState<Record<string, unknown>>({});

  return (
    <div className="space-y-3">
      <PageHeader title="Orders" />
      <QueryList<DemoOrder>
        schema={demoOrderSchema}
        listCode="demo-list-page-statistics-anti-create-wrong"
        adapter={adapterWrong}
        params={params}
        onParamsChange={(updates) =>
          setParams((prev) => ({ ...prev, ...updates }))
        }
        getRowId={(row) => row.id}
        recipe={{ filtering: false, selection: false, paginationMode: "offset" }}
        fillHeight={false}
        stats={
          <div className="space-y-3">
            <div className="flex items-center justify-between gap-2">
              <p className="text-sm font-medium text-muted-foreground">
                Summary
              </p>
              <Button type="button">
                <PlusIcon className="size-4" />
                Create order
              </Button>
            </div>
            <KpiCards params={params} />
          </div>
        }
      />
      <p className="text-muted-foreground text-xs">
        Create parked in the exclusive-area band
      </p>
    </div>
  );
}

function RightPanel() {
  const [params, setParams] = useState<Record<string, unknown>>({});

  return (
    <div className="space-y-3">
      <PageHeader
        title="Orders"
        extra={
          <Button type="button">
            <PlusIcon className="size-4" />
            Create order
          </Button>
        }
      />
      <QueryList<DemoOrder>
        schema={demoOrderSchema}
        listCode="demo-list-page-statistics-anti-create-right"
        adapter={adapterRight}
        params={params}
        onParamsChange={(updates) =>
          setParams((prev) => ({ ...prev, ...updates }))
        }
        getRowId={(row) => row.id}
        recipe={{ filtering: false, selection: false, paginationMode: "offset" }}
        fillHeight={false}
        stats={<KpiCards params={params} />}
      />
      <p className="text-muted-foreground text-xs">
        Create stays in PageHeader.extra on a resource index
      </p>
    </div>
  );
}

/** Create belongs in the page header — never in the stats exclusive area. */
export function ListPageStatisticsAntiCreateDemo() {
  return (
    <QueryClientProvider client={queryClient}>
      <DesignCompare wrong={<WrongPanel />} right={<RightPanel />} />
    </QueryClientProvider>
  );
}

When To Use

  • Surface 3–6 scoped KPIs that summarize the current list context (totals, counts, rates).
  • Prefer a simplified workbench band — not a full analytics / visualization dashboard.
  • Omit the slot when the product has no meaningful headline metrics.
  • Link out to a dedicated analytics page when users need trends, sparklines, or more than about six figures.
  • Do not put Create, Export, or column tools in the stats region — those stay PageHeader / toolbar (CRUD Page Patterns).

Page Order

Follow Ant’s research list single-column stack:

[Page header / actions]
→ Data filtering (QueryFilter and/or toolbar search/tabs)
→ Data statistics (StatisticGroup)     ← exclusive-area content
→ Data list (Table / List / Card)
→ Batch operations (selection bar / footer toolbar)

Stats Slot Stacking

Inside DataListShell’s stats (headless pages), default stack is:

  1. QueryFilter (variant="plain") on top when present. Default actionsLayout="rowEnd" keeps the filter to about one field row when collapsed so KPIs stay in the first viewport.
  2. Statistic Group below when present.
  3. Either alone is valid.
  4. Charts or custom content remain allowed in the same slot — prefer @f-ui/statistic for ordinary KPI figures.

QueryList recipe

Most list pages use QueryList, which already renders QueryFilter above the list card. Pass KPIs via stats?: ReactNode — they render between the filter and the table card (data-slot="query-list-stats"), same Ant stack without dropping the recipe:

<QueryList
  schema={schema}
  listCode="orders"
  adapter={adapter}
  params={params}
  onParamsChange={onParamsChange}
  getRowId={(row) => row.id}
  stats={
    <StatisticGroup>
      <Statistic variant="card" title="Orders" value={summary?.orderCount ?? null} loading={…} />
      {/* … */}
    </StatisticGroup>
  }
/>

Headless pages keep using useDataListQueryPage({ slots: { stats } })DataListShell stats={slots.stats}. See Table — List Page With Statistics and the Orders Table showcase.

Count

GuidanceValue
Recommended visible KPIs3–4
Soft maximum in one group6
Beyond thatLink out / separate analytics surface — do not cram the exclusive area

Dashboard budgets (e.g. visualization pages with many modules) do not apply here — the list exclusive area stays tighter.

Filters

Components stay presentational; the app owns fetch and filter binding.

ModeWhenRule
Default — filter-scopedMost resource indexesKPI query uses the same applied filter/params as the list; KPIs should update when filters change
Exception — globalExecutive / ALP-style headersKPIs intentionally ignore list filters; label the region (e.g. “Portfolio overview”) so users do not assume scoped totals

Backend Contract

Components stay presentational. Production KPIs need a server aggregate over the filtered set — never a sum of the current table page.

GET /orders?status=shipped&q=acme&page=1&pageSize=20&sort=orderDate:desc
GET /orders/summary?status=shipped&q=acme

(Demo showcase: list POST uses the full list body; summary POST accepts { filter } only — filter-scoped, no pagination fields.)

RuleDetail
Filter paritySummary accepts the same filter query keys as the list. Omit page, pageSize, cursor, and usually sort.
Response shapeFlat, UI-ready fields matching cards, e.g. { "orderCount": 1284, "totalAmount": 982341.5, "shippedCount": 910, "cancelledCount": 42 }.
AggregationServer-side (COUNT / SUM / …). Clients must not reduce paginated rows for filter-scoped totals.
Auth / tenantSame authorization and tenant scope as the list.
CachingShort TTL / ETag OK; invalidate with mutations that change aggregates.
Zero vs missingFiltered empty set → numeric zeros. Omit the stats band only when the product has no KPIs.

Acceptable Alternative — List aggregates

{
  "items": [],
  "totalItems": 1284,
  "aggregates": {
    "totalAmount": 982341.5,
    "shippedCount": 910,
    "cancelledCount": 42
  }
}

Use when one round-trip matters and aggregates are cheap. Prefer sibling /summary when aggregates are heavier, cached differently, or refreshed on a different cadence than pagination. totalItems alone is not a substitute for multi-metric KPIs.

Global KPIs (Exception)

Separate endpoint that ignores list filters (e.g. GET /orders/portfolio-summary). Label the UI region (e.g. “Portfolio overview”) so users do not assume scoped totals.

Frontend Data Loading

Pair the list fetch with a parallel summary query. In f-ui hosts, the list usually goes through useDataList / a server adapter (already TanStack Query); KPIs use a separate useQuery.

appliedFilters ──► list:    ['orders', 'list', filters, page, sort]
               └──► summary: ['orders', 'summary', filters]   // no page / sort

Use the applied inquire/toolbar filter slice (after QueryFilter submit), not draft form values.

ConcernRule
ParallelismList adapter + summary useQuery side by side
LoadingSummary pending → Statistic loading (titles stay). Do not blank the table solely because KPIs are pending if the list already has data
ErrorSummary failure → inline Retry in the stats region only — never list Empty / adapter Error takeover
RefetchplaceholderData: keepPreviousData so prior figures remain while filters change
InvalidationAfter mutations that change aggregates, invalidate list and summary keys (e.g. prefix ['orders'])
Selectionselection.count / selected amount belong on DataListSelectionBar — not in the exclusive-area KPI band
import { keepPreviousData, useQuery } from "@tanstack/react-query";

const summaryQuery = useQuery({
  queryKey: ["orders", "summary", filters],
  queryFn: () => fetchOrdersSummary(filters),
  placeholderData: keepPreviousData,
});

// summaryQuery.data → Statistic values
// summaryQuery.isPending → loading
// summaryQuery.isError → inline Retry → summaryQuery.refetch()

See Statistic for presentational props and the Table recipe demo for a dual-query mock.

Variants

ContextRecommended variant
List page stats slotcard
Detail PageHeader.extraContentplain

See Statistic for props, loading, and empty value treatment.

Async

The host that loads KPI data owns Loading / Empty / Error for the stats region (workspace async-container rule). Do not blank the list solely because KPIs are pending if the list already has usable data.

StateUI
LoadingPer-metric loading or group skeleton; keep titles visible
ErrorInline failure + Retry in the stats region — never collapse into list Empty
EmptyNo KPIs configured → omit the slot; successful zero with a product need to explain → short empty copy, not a blank strip
PopulatedStatistic Group

On refetch, keep prior figures when useful; optional subtle progress is fine.

Anti-Patterns

  • Create in stats — Create stays in the page header on a resource index.
  • Click-to-filter KPIs — not in v1; do not invent clickable cards that mutate filters without a product decision.
  • Treat KPI error as list empty — stats failure is inline recovery; list Empty is a successful empty set.
  • Hand-rolled oversized number stacks — use Statistic / Statistic Group instead of ad-hoc text-2xl metric rows.
  • Full dashboard in the exclusive area — keep the band modest; send heavy analytics elsewhere.
  • Page-row totals for filter-scoped titles — do not reduce the current page for “Total amount”; use /summary or list aggregates.
  • Mix unlabeled scopes — do not place filter-wide totalItems next to page-local sums without clear labels (prefer fixing the data source).
  • One HTTP call per KPI card — bundle metrics in one summary response when practical.
  • Selection in the exclusive-area band — keep selection metrics on the selection / batch bar.
  • Statistic — component API and demos.
  • Table — list body and recipes.
  • CRUD Page Patterns — list / detail / create / edit surfaces.
  • Backend + TanStack Query: sections above.

On this page