Related List
Object Page region card for a loose child collection with title, count, toolbar, and Loading/Empty/Error body.
Related List is the Salesforce-class Object Page region: one card with a title + count toolbar and a body slot for List or Table. It owns the region Loading / Empty / Error triad so hosts do not invent a second card chrome. Use Modal Form (or similar) in actions for Add — not a second Add on Empty. Job matrix (Members vs assigned keys vs attachments vs index): List Surfaces.
When To Use
- Embed a loose child collection on an Object Page (Members, Attachments, Activity) with its own count and region Add.
- Prefer this over hand-rolling a card + toolbar when the body is List or Table and the host owns fetch state.
- Row ops:
List.Itemactionsor TablerowActions. Checkboxes / batch belong on QueryList or a Related List Table body — not on presentationalList. - Do not use Related List as the resource index — that is QueryList (
view="table"orview="list") on a list page. - Do not use it for tight FormArray / line-item write tables — keep those as Table or Editable Table inside the form.
- Do not wrap the body in QueryList; Related List is preview chrome, not a filtered list report.
| Body | Use when | Selection / batch |
|---|---|---|
| List | Members, activity, grouped assigned keys | Row actions only. Add picker may use leading Checkbox. No kit batch bar |
| Table | Attachments, amounts, sortable columns | Optional Table selection; mass ops here or View All → QueryList |
Features
| Area | Behavior |
|---|---|
| Toolbar | One row: title (h2) + optional count on the left; tools then actions on the right |
| Body slot | Host passes List or Table as children when status="populated" |
| Status | Host-branch populated / loading / empty / error; toolbar stays mounted on every status |
| Empty | Default Empty has no action — Add lives only in the toolbar |
| Error | Default Result status="error" with optional Retry via onRetry |
| Padding | Same card as Object Page DetailCard: p-6 + gap-4 around title and Table/List. No header divider. bodyPadding="none" bleeds the body to the card edges (escape hatch). Chrome: Object Page Cards |
Installing
pnpm dlx shadcn@latest add https://ui.isaacfei.com/r/related-list.jsonnpx shadcn@latest add https://ui.isaacfei.com/r/related-list.jsonyarn dlx shadcn@latest add https://ui.isaacfei.com/r/related-list.jsonbun x shadcn@latest add https://ui.isaacfei.com/r/related-list.jsonWith a namespace: npx shadcn@latest add @f-ui/related-list.
registryDependencies: utils, https://ui.isaacfei.com/r/empty.json, https://ui.isaacfei.com/r/result.json, button, skeleton.
Usage
import { List } from "@/components/f-ui/list/list";
import { RelatedList } from "@/components/f-ui/related-list/related-list";
import { Button } from "@/components/ui/button";
<RelatedList
title="Members"
count={2}
actions={<Button type="button">Add members</Button>}
>
<List>
<List.Item>
<List.Meta title="Ada Lovelace" description="ada@example.com" />
</List.Item>
</List>
</RelatedList>Examples
List Body
Members region with count, primary Add on the toolbar, and a presentational List of two people with 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>
);
}Table Body
Attachments-style region with kit Table. The card is the same padded shell as Notes / DetailCard — title row, then a gap, then the table. Chrome: Object Page Cards. Do not pass bodyPadding="none" (that bleeds the grid to the card border). Do not hand-roll an HTML <table>.
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>
);
}Table Body With Mass Ops
When this child collection needs mass download / delete, enable Table selection and put the mass action in tools (outline). Add stays the solid actions control. Do not put checkboxes on a List body for this job. Prefer View All → QueryList when the child is really a filtered index.
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>
);
}Toolbar Tools And Add
Ghost Refresh (or Columns) lives in tools on the same row as the title. Add lives in actions. Do not stack them in a corner. Playbook: CRUD Page Patterns — Region Table Toolbar.
Members
2- Ada Lovelaceada@example.comOwner
- Grace Hoppergrace@example.comMember
"use client";
import { RefreshCwIcon } 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";
/** Ghost Refresh in `tools` shares the title baseline; Add stays the only solid control. */
export function RelatedListToolsDemo() {
return (
<RelatedList
actions={<Button type="button">Add members</Button>}
count={2}
title="Members"
tools={
<IconGhostButton label="Refresh">
<RefreshCwIcon className="size-4" />
</IconGhostButton>
}
>
<List>
<List.Item extra="Owner">
<List.Meta description="ada@example.com" title="Ada Lovelace" />
</List.Item>
<List.Item extra="Member">
<List.Meta description="grace@example.com" title="Grace Hopper" />
</List.Item>
</List>
</RelatedList>
);
}Assigned Keys Draft
Grouped capabilities: per-row Remove stages a Formily draft; Save writes the set. Do not put checkboxes on this list — the Add modal is the picker.
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>
);
});Loading, Empty, And Error
Switch Demo status in tools — the toolbar (including Add) stays mounted; Empty has no Add; Error shows Result with Retry. Give the card a height when you want Empty / Error centered in the remaining body; Loading skeleton stays top-aligned.
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>
);
}Composition
RelatedList
├─ toolbar (title + count | tools + actions)
└─ body
├─ populated → children (List | Table)
├─ loading → Skeleton (or loadingFallback)
├─ empty → Empty without action (or emptyFallback)
└─ error → Result + Retry (or errorFallback)Edge Cases And Errors
Do Not Wrap QueryList
Related List is Object Page preview chrome. Filters, pagination, and a batch footer belong on the resource index (QueryList). Full anti-pattern gallery: List Surfaces.
Empty Has No Second Add
Default Empty has no action. Region Add stays in the toolbar so operators always know where it lives. Do not pass emptyFallback that duplicates Add.
List Body Has No Kit Batch Bar
List.Item actions + region Add. Checkboxes on assigned Members / keys are wrong. A picker dialog may compose Checkbox into List leading.
status Does Not Fetch
You own the triad. Pass "loading" / "empty" / "error" from your query. The toolbar stays mounted on every status.
API Reference
Props
RelatedList
| Prop | Type | Default | Description |
|---|---|---|---|
title | ReactNode | — | Region heading (h2) |
count | ReactNode | — | Optional count beside the title (left cluster) |
tools | ReactNode | — | Secondary toolbar controls (refresh, column manager, demo status) |
actions | ReactNode | — | Primary region actions (usually one solid Add) |
status | "populated" | "loading" | "empty" | "error" | "populated" | Host-owned triad discriminant |
emptyTitle | ReactNode | "No data" | Default Empty title |
emptyDescription | ReactNode | — | Default Empty description |
errorTitle | ReactNode | "Couldn't load this list" | Default Result title |
errorDescription | ReactNode | — | Default Result subTitle |
onRetry | () => void | — | When set, shows outline Retry on default error Result |
loadingFallback | ReactNode | — | Replaces default skeleton bars |
emptyFallback | ReactNode | — | Replaces default Empty (do not put Add here) |
errorFallback | ReactNode | — | Replaces default Result |
bodyPadding | "default" | "none" | "default" | Default shares card p-6. "none" bleeds the body to the card edges |
id | string | — | Optional section id |
children | ReactNode | — | Body when status="populated" |
className | string | — | Root section class |