Skip to content

Data Views

<table> and <treeview> both take a plain data prop (rows/nodes) you own in React state, and both follow the same contract: the native widget renders exactly what you give it and asks you, via an event, when the user wants something to change. It never reorders or re-nests the data itself, so your React state stays the single source of truth, the same discipline <listview>’s items already follows.

The sourcetree sidebar widget with sections, badges, and a selected row on macOS (AppKit)

The sourcetree sidebar widget with sections, badges, and a selected row on GNOME (GTK)

A multi-column list, backed by GtkColumnView on GTK and NSTableView on macOS. You describe it with a columns array and a rows array kept separate, so a column resize or reorder never has to touch every row.

import type { TableColumn, TableRow } from "@nativedesktop/react";
const columns: TableColumn[] = [
{ id: "name", title: "Name" },
{ id: "role", title: "Role" },
{ id: "department", title: "Department", width: 160 },
];
function handleSortChanged(e: { data: unknown }) {
const { columnId, direction } = e.data as { columnId: string; direction: "ascending" | "descending" };
setEmployees((prev) => {
const sorted = [...prev].sort((a, b) => {
const cmp = String(a[columnId]).localeCompare(String(b[columnId]));
return direction === "ascending" ? cmp : -cmp;
});
return sorted;
});
}
<table
columns={columns}
rows={employees.map((e): TableRow => ({ id: e.id, cells: [e.name, e.role, e.department] }))}
selectedIndex={selectedIndex}
onSelectionChanged={(e) => setSelectedIndex(e.index)}
onRowActivated={(e) => setActivatedIndex(e.index)}
onSortChanged={handleSortChanged}
/>;
Prop Type Applied Notes
columns TableColumn[] createAndUpdate { id, title, width? }. id is echoed back in sortChanged, never shown.
rows TableRow[] createAndUpdate { id?, cells }. cells is positional, indexed by columns’ order.
selectedIndex int createAndUpdate Default -1.
showRowSeparators bool createAndUpdate Default true.
Event Handler Payload
selectionChanged onSelectionChanged { index }
rowActivated onRowActivated { index } (double-click / Enter)
sortChanged onSortChanged { data: { columnId, direction } }

Clicking a column header fires sortChanged and stops there: the native widget shows the sort indicator arrow but never reorders rows itself. Your onSortChanged handler owns sorting: re-sort rows in JS as above and pass the new array back down. This mirrors <listview>’s “native never mutates your data” contract and keeps sorting logic in one place, where you can test it outside the UI.

A hierarchical outline, backed by GtkTreeListView/GtkColumnView on GTK and NSOutlineView on macOS. You describe it as a flat array keyed by id/parentId, not nested JS objects. Root nodes omit parentId.

import type { TreeNode } from "@nativedesktop/react";
const nodeMeta: Omit<TreeNode, "expanded">[] = [
{ id: "fruits", title: "Fruits", hasChildren: true },
{ id: "apple", parentId: "fruits", title: "Apple", hasChildren: false },
{ id: "banana", parentId: "fruits", title: "Banana", hasChildren: false },
];
const [expanded, setExpanded] = useState<Set<string>>(new Set(["fruits"]));
const nodes: TreeNode[] = nodeMeta.map((n) => ({ ...n, expanded: expanded.has(n.id) }));
<treeview
nodes={nodes}
indentationPerLevel={16}
onSelectionChanged={(e) => setSelected((e.data as { nodeId: string | null }).nodeId)}
onNodeExpanded={(e) => {
const { nodeId } = e.data as { nodeId: string };
setExpanded((prev) => new Set(prev).add(nodeId));
}}
onNodeCollapsed={(e) => {
const { nodeId } = e.data as { nodeId: string };
setExpanded((prev) => { const next = new Set(prev); next.delete(nodeId); return next; });
}}
/>;
Prop Type Applied Notes
nodes TreeNode[] createAndUpdate { id, parentId?, title, badge?, iconName?, hasChildren, expanded }, a flat list rather than a nested one.
selectedIndex int createAndUpdate Default -1, indexes the flattened visible rows.
indentationPerLevel int create Pixels per depth level, default 16.
Event Handler Payload
selectionChanged onSelectionChanged { data: { nodeId } }
rowActivated onRowActivated { data: { nodeId } }
nodeExpanded onNodeExpanded { data: { nodeId } }
nodeCollapsed onNodeCollapsed { data: { nodeId } }

Expansion is controlled state, not native state: the widget asks (via nodeExpanded/ nodeCollapsed) rather than deciding on its own, so a re-render triggered by something else in your app can never silently collapse a branch the user opened. Track expansion yourself (a Set<string> of expanded ids, as above) and feed it back into every node’s expanded field.

A hierarchical sidebar: section headers, id/parentId rows with a caption line, badges, and trailing per-row actions. GTK renders a navigation-sidebar GtkListBox of AdwActionRows; macOS a .sourceList-style NSOutlineView. Use <sourcelist> for a flat index-addressed sidebar and <treeview> for a generic disclosure tree in a content pane; <sourcetree> is the one that owns “app chrome sidebar with structure”.

Rows are the same flat id/parentId shape as TreeView, with sidebar extras:

import type { SourceTreeAction, SourceTreeNode } from "@nativedesktop/react";
const actions: SourceTreeAction[] = [
{ id: "new-run", iconName: "list-add-symbolic", label: "New Run" },
{ id: "close-run", iconName: "window-close-symbolic", tooltip: "Close run", destructive: true },
];
const nodes: SourceTreeNode[] = [
{ id: "sec-hosts", title: "Hosts", section: true, hasChildren: true, expanded: true },
{ id: "host-mac", parentId: "sec-hosts", title: "macbook", caption: "connected",
iconName: "computer-symbolic", hasChildren: true, expanded: true },
{ id: "run-1", parentId: "host-mac", title: "fix sidebar", caption: "running · 2m",
badge: "3", actionIds: ["close-run"], testID: "run-row-1" },
];
<sourcetree
nodes={nodes}
actions={actions}
selectedId={selectedId}
onSelectionChanged={(e) => setSelectedId((e.data as { nodeId: string | null }).nodeId ?? "")}
onActionClicked={(e) => {
const { nodeId, actionId } = e.data as { nodeId: string; actionId: string };
// ...
}}
/>;
Prop Type Applied Notes
nodes SourceTreeNode[] createAndUpdate Flat { id, parentId?, title, caption?, iconName?, captionIconName?, badge?, section?, hasChildren?, expanded?, selectable?, actionIds?, testID? }.
actions SourceTreeAction[] createAndUpdate The action catalog rows reference by actionIds: { id, iconName, label?, tooltip?, destructive? }.
selectedId string createAndUpdate Controlled selection by node id; "" means none. There is no selectedIndex.
actionVisibility "hover" | "always" create Default "hover": action buttons show only while the pointer is over the row.
indentationPerLevel int create Pixels per depth level, default 14.
Event Handler Payload
selectionChanged onSelectionChanged { data: { nodeId } } (nodeId null on deselect)
rowActivated onRowActivated { data: { nodeId } }
nodeExpanded onNodeExpanded { data: { nodeId } }
nodeCollapsed onNodeCollapsed { data: { nodeId } }
actionClicked onActionClicked { data: { nodeId, actionId } }

Semantics shared with TreeView: expansion is controlled state (track a Set<string> of expanded ids and feed it back into expanded), and events address rows by nodeId because visible indexes shift under expand/collapse. section: true marks a group header: non-selectable, styled as a native section label on both platforms; a section with hasChildren is a collapsible shelf. selectable: false makes an informational row (an empty-state line) unselectable without making it a header. Hover is the widget’s own native affordance; there is deliberately no hoverChanged event, so apps stop hand-rolling per-row hover state.

Per-node testID surfaces in the automation tree’s rows (see the getTree docs), so tests target rows without depending on row order. Platform asymmetries: GTK draws a manual disclosure button per parent row (GtkListBox has no native outline affordance) and renders captionIconName as a small second prefix icon; macOS sections have no disclosure triangle (native source-list group look) and inline the caption icon on the caption line.

Every collection widget — <sourcelist>, <listview>, <table>, <treeview>, <sourcetree> — takes three optional createAndUpdate props: emptyIconName, emptyTitle, emptyDescription. When the item array is empty AND at least one of them is set, the widget shows platform empty-state chrome in place of the blank list (a compact AdwStatusPage on Linux, a centered icon/title/description stack on macOS) and swaps the real list back the moment items return:

<table
columns={columns}
rows={rows}
emptyIconName="folder-open"
emptyTitle="No results"
emptyDescription="Try a broader filter."
/>

Unset (the default) means no swap ever happens — existing lists are unaffected. This replaces the hand-composed <statuspage>-next-to-an-empty-list pattern.

See examples/gallery/main.tsx’s “Table”, “Tree” and “SourceTree” tabs for all three wired to full controlled state (including the JS-side sort), and the Widget Reference for the generated prop tables.