List Surfaces
Pick List, Related List, QueryList, or Table for every collection job — including row actions, checkboxes, and batch.
Four kit names look like “list.” They are four jobs. If you pick the wrong one, operators get a fake batch bar on five members, or a resource index with no filters.
| You are building | Use | Not |
|---|---|---|
| Stacked rows inside a card / dialog (title + description) | List | QueryList, Table, a hand-rolled ul |
| Object Page region (title + count + Add + Loading/Empty/Error) | Related List + List or Table body | QueryList inside the card |
| Full resource index (filters, pagination, selection, batch) | QueryList view="table" (default) or view="list" | Related List, presentational List with a handle |
| Columnar compare / sort / dense fields | Table (alone or as QueryList / Related List body) | List |
| Tight line-item write grid | Editable Table | Related List |
CRUD Page Patterns owns page vs region Create placement. This page owns which collection painter and where checkboxes / batch / row actions live.
If You See This
Walk the left column. Stop at the first match. Do not invent a fifth SKU.
| What you are looking at | Use | Do not |
|---|---|---|
| The page is the collection (Orders, users, tickets) | QueryList (view="table" or view="list") | Related List, presentational List + DataListHandle |
| Object Page card titled Members / Activity / Attachments | Related List + List or Table | QueryList, a bare h2 + stacked buttons |
| Five people on a role | Related List + List + row Remove + region Add | Checkboxes + Delete selected |
| Closed catalog of assigned keys | Related List + grouped List + Formily Save; Add is a picker | Checkboxes on the assigned list |
| Files / amounts / sortable columns on an object | Related List + Table | Hand-rolled <table>, List pretending to be columns |
| Mass download / delete on that child table | Related List + Table selection + toolbar action | List leading checkboxes + kit batch footer |
| Dialog: pick N people / keys | List + Checkbox in leading | rowSelection, QueryList, a batch bar |
| Resource index, row-first (title + description) | QueryList view="list" + rowLayout | A ProList recipe, sniffing schema, presentational List |
| Same index, columns / sort / compare | QueryList view="table" (default) | Related List |
| Index with no mass ops | QueryList with recipe.selection omitted (default off) | Copy-pasting checkboxes because the body is list-shaped |
| Line items the operator edits on a form | Editable Table | Related List |
| Empty / loading / error for a region | Related List status | Blank ul, Result as Empty, Spin over a missing toolbar |
| Empty / loading / error for the page | QueryList shell Empty / Error / skeleton | Related List status on the index |
Hard bans
- Do not put
rowSelection/batchActions/DataListHandleon presentational List. - Do not wrap a Related List body in QueryList.
- Do not use Related List as the resource index.
- Do not sniff schema to pick QueryList
view. The host setsview="table"orview="list". - Do not invent a
ProListrecipe. The list-shaped index is QueryListview="list". - Do not add an operator table↔list toggle in v1.
Who Owns The Checkbox
| Surface | Row actions | Checkboxes | Batch / mass bar |
|---|---|---|---|
| Presentational List | List.Item actions[] | Host may compose Checkbox into leading (picker only) | No kit footer |
ListView / QueryList view="list" | Shared rowActions | Same handle as Table | QueryList footer when you pass batchActions |
| Related List + List | List.Item actions | Default off | Region Add, not mass-update |
| Related List + Table | Table rowActions | Optional | Optional toolbar action, or View All → QueryList |
| Standalone Table | Optional | selection / handle | Host chrome if needed |
Anti-Patterns
Each pair is the same job done wrong, then right. Copy the Right column.
Batch Bar On Members
Problem: checkboxes on Related List + List, plus Delete selected as the region primary. Operators confuse “who is on this role” with “who is marked for delete,” and you skipped the Add picker.
Do: ghost Remove on each row; solid Add members on the toolbar. Multi-pick happens in the Add dialog (leading Checkbox), not on the assigned list.
Wrong
Members
2- Ada Lovelaceada@example.comOwner
- Grace Hoppergrace@example.comMember
Right
Members
2- Ada Lovelaceada@example.comOwner
- Grace Hoppergrace@example.comMember
"use client";
import { Trash2Icon } from "lucide-react";
import { List } from "@/components/f-ui/list/list";
import { RelatedList } from "@/components/f-ui/related-list/related-list";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import { DesignCompare } from "@/demos/_design/design-compare";
import { IconGhostButton } from "@/demos/_shared/icon-ghost-button";
function MemberRows({ withCheckboxes }: { withCheckboxes: boolean }) {
return (
<List>
<List.Item
extra="Owner"
leading={withCheckboxes ? <Checkbox checked={false} aria-label="Select Ada" /> : undefined}
actions={
withCheckboxes
? undefined
: [
<IconGhostButton key="rm" label="Remove Ada">
<Trash2Icon className="size-4" />
</IconGhostButton>,
]
}
>
<List.Meta description="ada@example.com" title="Ada Lovelace" />
</List.Item>
<List.Item
extra="Member"
leading={withCheckboxes ? <Checkbox checked={false} aria-label="Select Grace" /> : undefined}
actions={
withCheckboxes
? undefined
: [
<IconGhostButton key="rm" label="Remove Grace">
<Trash2Icon className="size-4" />
</IconGhostButton>,
]
}
>
<List.Meta description="grace@example.com" title="Grace Hopper" />
</List.Item>
</List>
);
}
export function ListSurfacesBatchOnRelatedListDemo() {
return (
<DesignCompare
wrong={
<RelatedList
actions={<Button type="button">Delete selected</Button>}
count={2}
title="Members"
>
<MemberRows withCheckboxes />
</RelatedList>
}
right={
<RelatedList
actions={<Button type="button">Add members</Button>}
count={2}
title="Members"
>
<MemberRows withCheckboxes={false} />
</RelatedList>
}
/>
);
}<RelatedList title="Members" count={members.length} actions={<Button>Add members</Button>}>
<List>
{members.map((row) => (
<List.Item
key={row.id}
actions={[<Button key="rm" variant="ghost" size="icon" aria-label="Remove" />]}
>
<List.Meta title={row.name} description={row.email} />
</List.Item>
))}
</List>
</RelatedList>QueryList Inside A Region Card
Problem: Search / filters / list-report chrome stuffed into Attachments. Related List is preview chrome, not a filtered index.
Do: Related List + Table (or List). Operators who need the full report use View All → QueryList.
Wrong
Attachments
2Search and filters belong on QueryList, not inside this card
| quote.pdf | 240 KB |
| packing-list.xlsx | 18 KB |
Right
Attachments
2| quote.pdf | 240 KB |
| packing-list.xlsx | 18 KB |
"use client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { defineDataListSchema } from "@/components/f-ui/data-list-internals/schema/define-data-list-schema";
import { f } from "@/components/f-ui/field-types/catalog";
import { RelatedList } from "@/components/f-ui/related-list/related-list";
import { Table } from "@/components/f-ui/table/table";
import { Button } from "@/components/ui/button";
import { DesignCompare } from "@/demos/_design/design-compare";
interface AttachmentRow {
id: string;
name: string;
size: string;
}
const ATTACHMENTS: AttachmentRow[] = [
{ id: "1", name: "quote.pdf", size: "240 KB" },
{ id: "2", name: "packing-list.xlsx", size: "18 KB" },
];
const attachmentSchema = defineDataListSchema<AttachmentRow>({
name: f.text({ label: "Name" }),
size: f.text({ label: "Size" }),
});
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
function AttachmentsTable({ listCode }: { listCode: string }) {
return (
<Table
data={ATTACHMENTS}
defaultPageSize="all"
getRowId={(row) => row.id}
listCode={listCode}
schema={attachmentSchema}
/>
);
}
function ListSurfacesQuerylistInRegionDemoInner() {
return (
<DesignCompare
right={
<RelatedList
actions={<Button type="button">Add attachment</Button>}
count={2}
title="Attachments"
>
<AttachmentsTable listCode="list-surfaces-region-right" />
</RelatedList>
}
wrong={
<RelatedList
actions={<Button type="button">Add attachment</Button>}
count={2}
title="Attachments"
>
<div className="space-y-2 p-3">
<p className="text-muted-foreground rounded-md border border-dashed px-3 py-2 text-sm">
Search and filters belong on QueryList, not inside this card
</p>
<AttachmentsTable listCode="list-surfaces-region-wrong" />
</div>
</RelatedList>
}
/>
);
}
/** Wrong: list-report chrome inside a region card. Right: Related List + Table only. */
export function ListSurfacesQuerylistInRegionDemo() {
return (
<QueryClientProvider client={queryClient}>
<ListSurfacesQuerylistInRegionDemoInner />
</QueryClientProvider>
);
}Related List As The Resource Index
Problem: the page is Orders, but the body is a Related List (count + region Add, no QueryList handle). You lose filters, pagination, and batch.
Do: page header Create + QueryList. Related List stays on the object page.
Wrong
Orders
Orders
128- ORD-1001Rush
- ORD-1002Standard
- ORD-1003Hold
Right
Orders
- ORD-1001Rush
- ORD-1002Standard
- ORD-1003Hold
"use client";
import { useState } from "react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { createInMemoryListAdapter } from "@/components/f-ui/data-list-internals/adapters/in-memory-list-adapter";
import { defineDataListSchema } from "@/components/f-ui/data-list-internals/schema/define-data-list-schema";
import { f } from "@/components/f-ui/field-types/catalog";
import { List } from "@/components/f-ui/list/list";
import { QueryList } from "@/components/f-ui/query-list/query-list";
import { RelatedList } from "@/components/f-ui/related-list/related-list";
import { Button } from "@/components/ui/button";
import { DesignCompare } from "@/demos/_design/design-compare";
interface DemoOrder {
id: string;
orderNumber: string;
notes: string;
}
const SEED: DemoOrder[] = [
{ id: "1", orderNumber: "ORD-1001", notes: "Rush" },
{ id: "2", orderNumber: "ORD-1002", notes: "Standard" },
{ id: "3", orderNumber: "ORD-1003", notes: "Hold" },
];
const schema = defineDataListSchema<DemoOrder>({
orderNumber: f.text({ label: "Order #" }),
notes: f.text({ label: "Notes" }),
});
const adapter = createInMemoryListAdapter<DemoOrder>({ items: SEED });
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false, refetchOnWindowFocus: false } },
});
function IndexWrong() {
return (
<div className="space-y-3">
<div className="flex items-center justify-between">
<p className="text-lg font-semibold">Orders</p>
<Button type="button">Create</Button>
</div>
<RelatedList
actions={<Button type="button">Add</Button>}
count={128}
title="Orders"
>
<List>
{SEED.map((row) => (
<List.Item key={row.id}>
<List.Meta description={row.notes} title={row.orderNumber} />
</List.Item>
))}
</List>
</RelatedList>
</div>
);
}
function IndexRight() {
const [params, setParams] = useState<Record<string, unknown>>({});
return (
<div className="space-y-3">
<div className="flex items-center justify-between">
<p className="text-lg font-semibold">Orders</p>
<Button type="button">Create</Button>
</div>
<QueryList<DemoOrder>
adapter={adapter}
fillHeight={false}
getRowId={(row) => row.id}
listCode="list-surfaces-index-right"
onParamsChange={(updates) => setParams((prev) => ({ ...prev, ...updates }))}
params={params}
recipe={{ filtering: false, paginationMode: "offset" }}
rowLayout={{ title: "orderNumber", description: "notes" }}
schema={schema}
view="list"
/>
</div>
);
}
function ListSurfacesRelatedListAsIndexDemoInner() {
return <DesignCompare right={<IndexRight />} wrong={<IndexWrong />} />;
}
/** Wrong: Related List as the resource index. Right: page header Create + QueryList. */
export function ListSurfacesRelatedListAsIndexDemo() {
return (
<QueryClientProvider client={queryClient}>
<ListSurfacesRelatedListAsIndexDemoInner />
</QueryClientProvider>
);
}<PageContainer title="Orders" extra={<Button>Create</Button>}>
<QueryList view="list" rowLayout={{ title: "orderNumber", description: "notes" }} … />
</PageContainer>Bare Heading Plus Stacked Tools
Problem: a hand-rolled h2, Add/Refresh stacked in a corner, then a naked List. That is a broken region, not a custom layout.
Do: Related List owns title, count, and one toolbar row.
Wrong
Members
- Ada Lovelaceada@example.comOwner
- Grace Hoppergrace@example.comMember
Right
Members
2- Ada Lovelaceada@example.comOwner
- Grace Hoppergrace@example.comMember
"use client";
import { Trash2Icon } from "lucide-react";
import { List } from "@/components/f-ui/list/list";
import { RelatedList } from "@/components/f-ui/related-list/related-list";
import { Button } from "@/components/ui/button";
import { IconGhostButton } from "@/demos/_shared/icon-ghost-button";
import { DesignCompare } from "@/demos/_design/design-compare";
function MemberRows() {
return (
<List>
<List.Item
actions={[
<IconGhostButton key="rm" label="Remove Ada">
<Trash2Icon className="size-4" />
</IconGhostButton>,
]}
extra="Owner"
>
<List.Meta description="ada@example.com" title="Ada Lovelace" />
</List.Item>
<List.Item
extra="Member"
actions={[
<IconGhostButton key="rm" label="Remove Grace">
<Trash2Icon className="size-4" />
</IconGhostButton>,
]}
>
<List.Meta description="grace@example.com" title="Grace Hopper" />
</List.Item>
</List>
);
}
/** Wrong: hand-rolled heading + stacked Add. Right: Related List owns title, count, toolbar. */
export function ListSurfacesBareListRegionDemo() {
return (
<DesignCompare
right={
<RelatedList
actions={<Button type="button">Add members</Button>}
count={2}
title="Members"
>
<MemberRows />
</RelatedList>
}
wrong={
<div className="space-y-3">
<h2 className="text-base font-semibold">Members</h2>
<div className="flex flex-col items-end gap-2">
<Button type="button">Add members</Button>
<Button type="button" variant="ghost">
Refresh
</Button>
</div>
<MemberRows />
</div>
}
/>
);
}Every Job
Object Page Members — No Batch Bar
Live Members region: count, primary Add, ghost Remove.
Members
2- Ada Lovelaceada@example.comOwner
- Grace Hoppergrace@example.comMember
"use client";
import { Trash2Icon } from "lucide-react";
import { List } from "@/components/f-ui/list/list";
import { RelatedList } from "@/components/f-ui/related-list/related-list";
import { Button } from "@/components/ui/button";
import { IconGhostButton } from "@/demos/_shared/icon-ghost-button";
export function RelatedListMembersDemo() {
return (
<RelatedList
title="Members"
count={2}
actions={
<Button type="button" variant="default">
Add members
</Button>
}
>
<List>
<List.Item
extra="Owner"
actions={[
<IconGhostButton key="rm" label="Remove">
<Trash2Icon className="size-4" />
</IconGhostButton>,
]}
>
<List.Meta title="Ada Lovelace" description="ada@example.com" />
</List.Item>
<List.Item
extra="Member"
actions={[
<IconGhostButton key="rm" label="Remove">
<Trash2Icon className="size-4" />
</IconGhostButton>,
]}
>
<List.Meta title="Grace Hopper" description="grace@example.com" />
</List.Item>
</List>
</RelatedList>
);
}Assigned Keys — Draft Remove, Then Save
Closed catalogs (a dozen assignable keys) do not need a batch bar. Remove stages a Formily draft; Save is the write. Add uses a picker modal with checkboxes.
Assigned capabilities
3"use client";
import { useMemo } from "react";
import { observer } from "@formily/react";
import { Trash2Icon } from "lucide-react";
import { Form } from "@/components/f-ui/formily/form";
import { FormActions } from "@/components/f-ui/formily/form-actions";
import { createForm } from "@/components/f-ui/formily/internals/create-form";
import { List } from "@/components/f-ui/list/list";
import { RelatedList } from "@/components/f-ui/related-list/related-list";
import { Button } from "@/components/ui/button";
import { IconGhostButton } from "@/demos/_shared/icon-ghost-button";
type Capability = { key: string; group: string; title: string; description: string };
const CATALOG: Capability[] = [
{ key: "orders.read", group: "Orders", title: "Read orders", description: "View the orders index and detail." },
{ key: "orders.write", group: "Orders", title: "Write orders", description: "Create and edit orders." },
{ key: "files.read", group: "Files", title: "Read files", description: "Download attachments." },
];
type FormValues = { keys: string[] };
/** Assigned keys on an object page: row Remove stages a draft; Save is the write. No checkboxes. */
export const RelatedListDraftRemoveDemo = observer(function RelatedListDraftRemoveDemo() {
const form = useMemo(
() => createForm<FormValues>({ values: { keys: CATALOG.map((item) => item.key) } }),
[],
);
const assigned = CATALOG.filter((item) => form.values.keys.includes(item.key));
const groups = [...new Set(assigned.map((item) => item.group))];
return (
<RelatedList count={assigned.length} title="Assigned capabilities">
<Form
form={form}
onSubmit={async () => undefined}
>
<List>
{groups.map((group) => (
<List.Group heading={group} key={group}>
{assigned
.filter((item) => item.group === group)
.map((item) => (
<List.Item
actions={[
<IconGhostButton
key="rm"
label={`Remove ${item.title}`}
onClick={() => {
form.setValues({
keys: form.values.keys.filter((key) => key !== item.key),
});
}}
>
<Trash2Icon className="size-4" />
</IconGhostButton>,
]}
key={item.key}
>
<List.Meta description={item.description} title={item.title} />
</List.Item>
))}
</List.Group>
))}
</List>
<FormActions align="end" className="pt-3" offset={false}>
<Button type="submit">Save</Button>
</FormActions>
</Form>
</RelatedList>
);
});Picker Dialog — Composed Checkbox
When the list is a picker, put a Checkbox in List.Item leading. That is host state, not rowSelection.
- Ada Lovelaceada@example.com
- Grace Hoppergrace@example.com
- Alan Turingalan@example.com
"use client";
import { useState } from "react";
import { List } from "@/components/f-ui/list/list";
import { Checkbox } from "@/components/ui/checkbox";
const PEOPLE = [
{ id: "ada", name: "Ada Lovelace", email: "ada@example.com" },
{ id: "grace", name: "Grace Hopper", email: "grace@example.com" },
{ id: "alan", name: "Alan Turing", email: "alan@example.com" },
];
/** Dialog / Transfer-style picker: host Checkbox in `leading`. Not a batch bar. */
export function ListLeadingPickerDemo() {
const [picked, setPicked] = useState<string[]>(["ada"]);
return (
<List>
{PEOPLE.map((person) => {
const checked = picked.includes(person.id);
return (
<List.Item
key={person.id}
leading={
<Checkbox
aria-label={`Select ${person.name}`}
checked={checked}
onCheckedChange={(value) => {
const on = value === true;
setPicked((current) =>
on
? [...current, person.id]
: current.filter((id) => id !== person.id),
);
}}
/>
}
>
<List.Meta description={person.email} title={person.name} />
</List.Item>
);
})}
</List>
);
}Attachments — Table Body
Columnar children use kit Table, not a hand-rolled <table>. Default: no selection.
Attachments
2| quote.pdf | 240 KB |
| packing-list.xlsx | 18 KB |
"use client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { defineDataListSchema } from "@/components/f-ui/data-list-internals/schema/define-data-list-schema";
import { RelatedList } from "@/components/f-ui/related-list/related-list";
import { Table } from "@/components/f-ui/table/table";
import { f } from "@/components/f-ui/field-types/catalog";
import { Button } from "@/components/ui/button";
interface AttachmentRow {
id: string;
name: string;
size: string;
}
const ATTACHMENTS: AttachmentRow[] = [
{ id: "1", name: "quote.pdf", size: "240 KB" },
{ id: "2", name: "packing-list.xlsx", size: "18 KB" },
];
const attachmentSchema = defineDataListSchema<AttachmentRow>({
name: f.text({ label: "Name" }),
size: f.text({ label: "Size" }),
});
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
function RelatedListTableDemoInner() {
return (
<RelatedList
title="Attachments"
count={2}
actions={
<Button type="button" variant="default">
Add attachment
</Button>
}
>
<Table
schema={attachmentSchema}
data={ATTACHMENTS}
listCode="related-list-attachments-demo"
getRowId={(row) => row.id}
defaultPageSize="all"
/>
</RelatedList>
);
}
export function RelatedListTableDemo() {
return (
<QueryClientProvider client={queryClient}>
<RelatedListTableDemoInner />
</QueryClientProvider>
);
}Attachments — Mass Ops On The Child Table
When this child does need mass download / delete, put checkboxes on Table, and the mass action on the Related List toolbar (ghost/outline). Add stays the solid primary. Do not put that machine on List.
Attachments
3| quote.pdf | 240 KB | |
| packing-list.xlsx | 18 KB | |
| label.pdf | 18 KB |
"use client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { toast } from "sonner";
import { defineDataListSchema } from "@/components/f-ui/data-list-internals/schema/define-data-list-schema";
import { useDataList } from "@/components/f-ui/data-list-internals/use-data-list";
import { f } from "@/components/f-ui/field-types/catalog";
import { RelatedList } from "@/components/f-ui/related-list/related-list";
import { Table } from "@/components/f-ui/table/table";
import { Button } from "@/components/ui/button";
interface AttachmentRow {
id: string;
name: string;
size: string;
}
const ATTACHMENTS: AttachmentRow[] = [
{ id: "1", name: "quote.pdf", size: "240 KB" },
{ id: "2", name: "packing-list.xlsx", size: "18 KB" },
{ id: "3", name: "label.pdf", size: "18 KB" },
];
const attachmentSchema = defineDataListSchema<AttachmentRow>({
name: f.text({ label: "Name" }),
size: f.text({ label: "Size" }),
});
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
function RelatedListTableSelectionDemoInner() {
const handle = useDataList({
schema: attachmentSchema,
listCode: "related-list-attachments-select-demo",
data: ATTACHMENTS,
getRowId: (row) => row.id,
defaultPageSize: "all",
features: { selection: true },
});
const selectedCount = handle.selection.count;
return (
<RelatedList
actions={<Button type="button">Add attachment</Button>}
count={ATTACHMENTS.length}
title="Attachments"
tools={
<Button
disabled={selectedCount === 0}
type="button"
variant="outline"
onClick={() => {
toast.success(`Download ${selectedCount}`);
handle.selection.clear();
}}
>
Download selected
</Button>
}
>
<Table dataList={handle} />
</RelatedList>
);
}
/** Mass ops on a columnar child: Table checkboxes + toolbar action. Not List checkboxes. */
export function RelatedListTableSelectionDemo() {
return (
<QueryClientProvider client={queryClient}>
<RelatedListTableSelectionDemoInner />
</QueryClientProvider>
);
}Region Loading, Empty, And Error
Switch Demo status. The toolbar (including Add) stays mounted; Empty has no second Add; Error shows Retry. Do not leave a blank ul.
Members
2- Ada Lovelaceada@example.com
- Grace Hoppergrace@example.com
"use client";
import { useState } from "react";
import { List } from "@/components/f-ui/list/list";
import {
RelatedList,
type RelatedListStatus,
} from "@/components/f-ui/related-list/related-list";
import { Button } from "@/components/ui/button";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
const STATUS_OPTIONS: { value: RelatedListStatus; label: string }[] = [
{ value: "populated", label: "Populated" },
{ value: "loading", label: "Loading" },
{ value: "empty", label: "Empty" },
{ value: "error", label: "Error" },
];
export function RelatedListStatusDemo() {
const [status, setStatus] = useState<RelatedListStatus>("populated");
return (
<RelatedList
title="Members"
count={status === "populated" ? 2 : 0}
status={status}
emptyTitle="No members yet."
emptyDescription="Add people who should have this role."
errorTitle="Couldn't load this list"
errorDescription="Check your connection and try again."
onRetry={() => setStatus("populated")}
tools={
<label className="flex items-center gap-2 text-sm text-muted-foreground">
<span className="whitespace-nowrap">Demo status</span>
<Select
value={status}
onValueChange={(value) => setStatus(value as RelatedListStatus)}
>
<SelectTrigger aria-label="Demo status" className="w-32">
<SelectValue />
</SelectTrigger>
<SelectContent>
{STATUS_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
</label>
}
actions={
<Button type="button" variant="default">
Add members
</Button>
}
>
<List>
<List.Item>
<List.Meta title="Ada Lovelace" description="ada@example.com" />
</List.Item>
<List.Item>
<List.Meta title="Grace Hopper" description="grace@example.com" />
</List.Item>
</List>
</RelatedList>
);
}Resource Index — QueryList List View With Batch
Same handle as Table: selection, batch, row actions. No column manager. This is the only list-shaped surface that ships a batch bar.
"use client";
import { useState } from "react";
import { PencilIcon } from "lucide-react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { toast } from "sonner";
import { defineDataListSchema } from "@/components/f-ui/data-list-internals/schema/define-data-list-schema";
import { createInMemoryListAdapter } from "@/components/f-ui/data-list-internals/adapters/in-memory-list-adapter";
import { QueryList } from "@/components/f-ui/query-list/query-list";
import { f } from "@/components/f-ui/field-types/catalog";
interface DemoOrder {
id: string;
orderNumber: string;
customer: string;
notes: string;
amount: number;
}
const SEED: DemoOrder[] = [
{ id: "1", orderNumber: "ORD-1001", customer: "Acme", notes: "Rush", amount: 2499 },
{ id: "2", orderNumber: "ORD-1002", customer: "Globex", notes: "Standard", amount: 849.5 },
{ id: "3", orderNumber: "ORD-1003", customer: "Initech", notes: "Hold for packing", amount: 120 },
{ id: "4", orderNumber: "ORD-1004", customer: "Umbrella", notes: "Cancelled hold", amount: 399 },
];
const schema = defineDataListSchema<DemoOrder>({
orderNumber: f.link({
label: "Order #",
href: (row) => `#${row.id}`,
}),
customer: f.text({ label: "Customer" }),
notes: f.text({ label: "Notes" }),
amount: f.currency({ label: "Amount", currency: "USD" }),
});
const adapter = createInMemoryListAdapter<DemoOrder>({ items: SEED });
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false, refetchOnWindowFocus: false } },
});
function QueryListListViewDemoInner() {
const [params, setParams] = useState<Record<string, unknown>>({});
return (
<QueryList<DemoOrder>
adapter={adapter}
batchActions={[
{
id: "export",
label: "Export",
onClick: ({ count, clear }) => {
toast.success(`Export ${count}`);
clear();
},
},
]}
fillHeight={false}
getRowId={(row) => row.id}
listCode="demo-query-list-list-view"
onParamsChange={(updates) => setParams((prev) => ({ ...prev, ...updates }))}
params={params}
recipe={{ filtering: false, selection: true, paginationMode: "offset" }}
rowActions={[
{
id: "edit",
label: "Edit",
icon: <PencilIcon className="size-3.5" />,
onClick: (row) => {
toast.info(`Edit ${row.orderNumber}`);
},
},
]}
rowActionsPresentation="icon"
rowLayout={{ title: "orderNumber", description: "notes", extra: ["amount"] }}
schema={schema}
view="list"
/>
);
}
/** Resource index with row body: same handle as Table — checkboxes, batch bar, row actions. */
export function QueryListListViewDemo() {
return (
<QueryClientProvider client={queryClient}>
<QueryListListViewDemoInner />
</QueryClientProvider>
);
}<QueryList
view="list"
rowLayout={{ title: "orderNumber", description: "notes", extra: ["amount"] }}
recipe={{ filtering: false, selection: true, paginationMode: "offset" }}
rowActions={[{ id: "edit", label: "Edit", onClick: (row) => { /* … */ } }]}
batchActions={[{ id: "export", label: "Export", onClick: ({ clear }) => clear() }]}
schema={schema}
listCode="orders"
adapter={adapter}
params={params}
onParamsChange={setParams}
getRowId={(row) => row.id}
/>Resource Index — QueryList List View Without Batch
view="list" does not turn checkboxes on. Omit recipe.selection (and batchActions) when the job is browse + identity Open only — put Open on f.link, do not pass rowActions.
"use client";
import { useState } from "react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { createInMemoryListAdapter } from "@/components/f-ui/data-list-internals/adapters/in-memory-list-adapter";
import { defineDataListSchema } from "@/components/f-ui/data-list-internals/schema/define-data-list-schema";
import { f } from "@/components/f-ui/field-types/catalog";
import { QueryList } from "@/components/f-ui/query-list/query-list";
interface DemoOrder {
id: string;
orderNumber: string;
notes: string;
amount: number;
}
const SEED: DemoOrder[] = [
{ id: "1", orderNumber: "ORD-1001", notes: "Rush", amount: 2499 },
{ id: "2", orderNumber: "ORD-1002", notes: "Standard", amount: 849.5 },
{ id: "3", orderNumber: "ORD-1003", notes: "Hold for packing", amount: 120 },
];
const schema = defineDataListSchema<DemoOrder>({
orderNumber: f.link({
label: "Order #",
href: (row) => `#${row.id}`,
}),
notes: f.text({ label: "Notes" }),
amount: f.currency({ label: "Amount", currency: "USD" }),
});
const adapter = createInMemoryListAdapter<DemoOrder>({ items: SEED });
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false, refetchOnWindowFocus: false } },
});
function QueryListListViewBrowseDemoInner() {
const [params, setParams] = useState<Record<string, unknown>>({});
return (
<QueryList<DemoOrder>
adapter={adapter}
fillHeight={false}
getRowId={(row) => row.id}
listCode="demo-query-list-list-view-browse"
onParamsChange={(updates) => setParams((prev) => ({ ...prev, ...updates }))}
params={params}
recipe={{ filtering: false, paginationMode: "offset" }}
rowLayout={{ title: "orderNumber", description: "notes", extra: ["amount"] }}
schema={schema}
view="list"
/>
);
}
/** Resource index, row-first, no checkboxes. Open lives on the identity link — no Actions column. */
export function QueryListListViewBrowseDemo() {
return (
<QueryClientProvider client={queryClient}>
<QueryListListViewBrowseDemoInner />
</QueryClientProvider>
);
}Headless compose uses ListView inside DataListShell — same painter QueryList uses. Showcase: /showcases/orders-list.
Row paint has three rungs, all on QueryList view="list": field-key rowLayout, slot functions, or renderItem returning a List.Item (wrap defaultItem or replace it). Same element presentational List renderItem already returns; QueryList adds the third defaultItem argument and clones measurement chrome.
A height-constrained list body with pagination.mode: "cursor" loads on scroll: ListView virtualizes its rows and owns the sentinel. Without a height cap it stays the button DataListLoadMore recipe. Details: Query List — Infinite Scroll.
Decision Tree
Is this the resource index (the page is the collection)?
yes → QueryList
need columns / sort / compare? → view="table" (default)
row-first title + description? → view="list" + rowLayout
need mass ops? → recipe.selection + batchActions (opt-in, either view)
no → Is this an Object Page region (title + count + Add)?
yes → Related List
identity / grouped keys? → List body (row actions, no kit batch)
columnar files / amounts? → Table body
need mass update on this child? → Table selection + toolbar action,
or View All → QueryList
loading / empty / error? → RelatedList status (do not blank the body)
no → dialog / card / picker?
yes → List (optional leading Checkbox; no kit batch footer)
write grid of lines? → Editable TableRelated Pages
- List — primitive API and row demos
- Related List — region chrome
- Query List — index recipe, including
view="list" - CRUD Page Patterns — where Create lives
- Row Actions Column — Table / QueryList action chrome
- Page And Region Status — Loading / Empty / Error
Row Actions Column
How to design table row Actions — identity Open, icon chrome, hide vs disable, trailing Delete, danger-only / multi-danger, and overflow when a row has many ops.
CRUD Page Patterns
Choose list, detail, create, and edit surfaces the way Ant Design Pro does — mapped to f-ui PageContainer, QueryList, Descriptions, and FormPage.