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.
- Data filtering (QueryFilter)
- Data statistics (Statistic Group)
- Data list (table)
| ORD-1001 | Acme | Confirmed | 2,499.00 |
| ORD-1002 | Globex | Shipped | 849.50 |
| ORD-1003 | Initech | Confirmed | 120.00 |
| ORD-1004 | Umbrella | Cancelled | 399.00 |
| ORD-1005 | Stark | Shipped | 9,800.00 |
| ORD-1006 | Wayne | Confirmed | 540.00 |
"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
Nine cards — analytics dashboard, not a list exclusive area
Right
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).
| ORD-1001 | Acme | Confirmed | 2,499.00 |
| ORD-1002 | Globex | Shipped | 849.50 |
| ORD-1003 | Initech | Confirmed | 120.00 |
| ORD-1004 | Umbrella | Cancelled | 399.00 |
| ORD-1005 | Stark | Shipped | 9,800.00 |
| ORD-1006 | Wayne | Confirmed | 540.00 |
"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.
| ORD-1001 | Acme | Confirmed | 2,499.00 |
| ORD-1002 | Globex | Shipped | 849.50 |
| ORD-1003 | Initech | Confirmed | 120.00 |
| ORD-1004 | Umbrella | Cancelled | 399.00 |
| ORD-1005 | Stark | Shipped | 9,800.00 |
| ORD-1006 | Wayne | Confirmed | 540.00 |
"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
| ORD-1001 | Acme | Confirmed | 2,499.00 |
| ORD-1002 | Globex | Shipped | 849.50 |
| ORD-1003 | Initech | Confirmed | 120.00 |
| ORD-1004 | Umbrella | Cancelled | 399.00 |
| ORD-1005 | Stark | Shipped | 9,800.00 |
| ORD-1006 | Wayne | Confirmed | 540.00 |
Create parked in the exclusive-area band
Right
Orders
| ORD-1001 | Acme | Confirmed | 2,499.00 |
| ORD-1002 | Globex | Shipped | 849.50 |
| ORD-1003 | Initech | Confirmed | 120.00 |
| ORD-1004 | Umbrella | Cancelled | 399.00 |
| ORD-1005 | Stark | Shipped | 9,800.00 |
| ORD-1006 | Wayne | Confirmed | 540.00 |
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:
- QueryFilter (
variant="plain") on top when present. DefaultactionsLayout="rowEnd"keeps the filter to about one field row when collapsed so KPIs stay in the first viewport. - Statistic Group below when present.
- Either alone is valid.
- Charts or custom content remain allowed in the same slot — prefer
@f-ui/statisticfor 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
| Guidance | Value |
|---|---|
| Recommended visible KPIs | 3–4 |
| Soft maximum in one group | 6 |
| Beyond that | Link 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.
| Mode | When | Rule |
|---|---|---|
| Default — filter-scoped | Most resource indexes | KPI query uses the same applied filter/params as the list; KPIs should update when filters change |
| Exception — global | Executive / ALP-style headers | KPIs 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.
Recommended — Sibling Summary Endpoint
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.)
| Rule | Detail |
|---|---|
| Filter parity | Summary accepts the same filter query keys as the list. Omit page, pageSize, cursor, and usually sort. |
| Response shape | Flat, UI-ready fields matching cards, e.g. { "orderCount": 1284, "totalAmount": 982341.5, "shippedCount": 910, "cancelledCount": 42 }. |
| Aggregation | Server-side (COUNT / SUM / …). Clients must not reduce paginated rows for filter-scoped totals. |
| Auth / tenant | Same authorization and tenant scope as the list. |
| Caching | Short TTL / ETag OK; invalidate with mutations that change aggregates. |
| Zero vs missing | Filtered 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 / sortUse the applied inquire/toolbar filter slice (after QueryFilter submit), not draft form values.
| Concern | Rule |
|---|---|
| Parallelism | List adapter + summary useQuery side by side |
| Loading | Summary pending → Statistic loading (titles stay). Do not blank the table solely because KPIs are pending if the list already has data |
| Error | Summary failure → inline Retry in the stats region only — never list Empty / adapter Error takeover |
| Refetch | placeholderData: keepPreviousData so prior figures remain while filters change |
| Invalidation | After mutations that change aggregates, invalidate list and summary keys (e.g. prefix ['orders']) |
| Selection | selection.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
| Context | Recommended variant |
|---|---|
List page stats slot | card |
Detail PageHeader.extraContent | plain |
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.
| State | UI |
|---|---|
| Loading | Per-metric loading or group skeleton; keep titles visible |
| Error | Inline failure + Retry in the stats region — never collapse into list Empty |
| Empty | No KPIs configured → omit the slot; successful zero with a product need to explain → short empty copy, not a blank strip |
| Populated | Statistic 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-2xlmetric rows. - Full dashboard in the exclusive area — keep the band modest; send heavy analytics elsewhere.
- Page-row totals for filter-scoped titles — do not
reducethe current page for “Total amount”; use/summaryor listaggregates. - Mix unlabeled scopes — do not place filter-wide
totalItemsnext 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.
Related Docs
- Statistic — component API and demos.
- Table — list body and recipes.
- CRUD Page Patterns — list / detail / create / edit surfaces.
- Backend + TanStack Query: sections above.
Domain Status Vs Derived View
Separate persisted lifecycle status from derived business views, UI chrome, and capability gates — so frontend labels may differ from backend enums without inventing a second source of truth.
Calendar
Preline-styled calendar powered by Vanilla Calendar Pro for single, range, time, and confirmable workflows.