Skip to content

Data Views

<table>, <treeview>, and <sourcetree> take a plain data prop you own in React state. The native widget renders exactly what you give it and fires an event when the user wants something to change. It never reorders or re-nests the data itself.

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. GtkColumnView on GTK, NSTableView on macOS. Columns and rows are separate arrays, so a column resize or reorder never has to touch every row. On macOS the table draws in the system’s inset style (rounded corners, no alternating row background), matching the macOS 26 Files and Mail list chrome.

A sorted Table with three columns on macOS (AppKit)

A sorted Table with three columns on GNOME (GTK)

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.
selectionMode "none" | "single" | "multiple" create Default "single". Create-only: change it by remounting the node (a different key).
selectedIndex int createAndUpdate The primary selected row. Default -1.
selectedIndexes number[] createAndUpdate The full selection, meaningful once selectionMode="multiple".
columnsReorderable bool createAndUpdate Lets the user drag a column header to reorder it. Default false.
showRowSeparators bool createAndUpdate Default true.
Event Handler Payload
selectionChanged onSelectionChanged { index, data: { indexes } }, index the primary row and data.indexes the full selection
rowActivated onRowActivated { index } (double-click / Enter)
sortChanged onSortChanged { data: { columnId, direction } }
columnsResized onColumnsResized { data }, the new column widths
columnsReordered onColumnsReordered { data }, the new column order

Clicking a column header fires sortChanged and stops there. The widget shows the sort indicator arrow but does not reorder rows. Sort in your handler and pass the new array back down. Dragging a header with columnsReorderable set works the same way: the widget fires columnsReordered and waits for you to feed the new columns order back down, rather than reordering on its own.

With selectionMode="multiple", click a row to select just it and Cmd/Ctrl-click or Shift-click to extend the selection, same as a native multi-select list. selectedIndexes is the source of truth for what’s highlighted; keep it in state and read e.data.indexes in onSelectionChanged. See examples/parity/main.tsx’s Data section.

A hierarchical outline. GtkTreeListView/GtkColumnView on GTK, NSOutlineView on macOS. Nodes are a flat array keyed by id/parentId, not nested objects. Root nodes omit parentId. Draws in the same inset style as <table> on macOS.

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 }, flat rather than nested.
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. Track a Set<string> of expanded ids as above and feed it back into every node’s expanded field, otherwise an unrelated re-render can collapse a branch the user opened.

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.

Pick between the three sidebar-ish widgets: <sourcelist> for a flat index-addressed sidebar, <treeview> for a disclosure tree in a content pane, <sourcetree> for an app chrome sidebar with structure.

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?, iconData?, captionIconName?, badge?, section?, hasChildren?, expanded?, selectable?, actionIds?, testID? }. iconName is a freedesktop icon name; iconData is raw image bytes (a data: URL or bare base64) for icons no theme has, such as a favicon, and wins when both are set.
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. Unset, each backend uses its native step: 24 on GTK, 14 on macOS.
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 } }

Expansion is controlled state, same as TreeView. Events address rows by nodeId because visible indexes shift under expand and collapse.

Row kinds:

  • section: true is a group header. Non-selectable, styled as a native section label. A section with hasChildren is a collapsible shelf.
  • selectable: false is an informational row, such as an empty-state line, that is not a header.

There is no hoverChanged event. Hover is the widget’s own native affordance.

Per-node testID surfaces in the automation tree’s rows, so tests target rows without depending on row order.

Platform differences: GTK draws a manual disclosure arrow per parent row (GtkListBox has no native outline affordance), reserves that 24px gutter on every row so a parent’s title stays left of its children’s, paints the sidebar-pane fill when the tree is not already in a split view’s sidebar slot, and renders captionIconName as a small second prefix icon. macOS sections have no disclosure triangle (the native source-list group look) and inline the caption icon on the caption line.

<sourcelist>, <listview>, <table>, <treeview>, and <sourcetree> all take 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 when items return.

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

Leave them unset and no swap ever happens.

See examples/gallery/main.tsx’s Table, Tree, and SourceTree tabs for all three wired to controlled state, and the Widget Reference for the generated prop tables.