DataView
A unified data primitive whose query state drives swappable renderers. List, custom, multi-view — one filter/sort/group/search model.1<DataView2 data={data}3 fields={fields}4 defaultSort={{ name: "name", order: "asc" }}5>6 <DataView.Toolbar>7 <DataView.Filters />8 <DataView.DisplayControls />9 </DataView.Toolbar>10 <DataView.List variant="table" columns={tableColumns} />11</DataView>
Overview
DataView owns the data layer — query state (filters, sort, group, search), client/server mode, row model derivation — and lets you pick the renderer that draws it. The same <DataView> can host a table, a list, or a free-form view, switchable at runtime without losing query state.
In scope today: DataView.List (table + list presentations) and DataView.Custom (escape hatch for cards, kanban, gallery, etc.).
Anatomy
1import {2 DataView,3 DataViewField,4 DataViewListColumn,5 ViewSpec,6 useDataView,7 EmptyFilterValue,8} from "@raystack/apsara";910<DataView data={data} fields={fields} defaultSort={defaultSort}>11 <DataView.Toolbar>12 <DataView.Search />13 <DataView.Filters />14 <DataView.DisplayControls />15 </DataView.Toolbar>1617 <DataView.List variant="table" columns={tableColumns} />1819 <DataView.EmptyState>{/* no matches */}</DataView.EmptyState>20 <DataView.ZeroState>{/* no data yet */}</DataView.ZeroState>21</DataView>
Core ideas
Fields vs columns
fields is renderer-agnostic metadata declared once on the root — filter capability, sort capability, group capability, visibility, group-header presentation. Cell/header renderers live on the renderer's column spec (e.g. DataView.List's columns).
1const fields: DataViewField<Person>[] = [2 { accessorKey: "name", label: "Name", sortable: true, filterable: true, filterType: "string", hideable: true },3 { accessorKey: "team", label: "Team", filterable: true, filterType: "select", groupable: true, filterOptions: [...] },4 { accessorKey: "email", label: "Email", hideable: true, defaultHidden: true },5];67const tableColumns: DataViewListColumn<Person>[] = [8 { accessorKey: "name", width: "1fr", cell: ({ row }) => <Text>{row.original.name}</Text> },9 { accessorKey: "team", width: "auto", cell: ({ row }) => <Badge>{row.original.team}</Badge> },10 { accessorKey: "email", width: "1fr", cell: ({ row }) => <Text>{row.original.email}</Text> },11];
Empty vs zero state
Empty/zero is computed once on context (isEmptyState, isZeroState) and exposed as sibling components.
- Zero state — no data, no active query. The "first-use" surface. Toolbar is hidden automatically.
- Empty state — no rows visible because filters/search/sort exclude them all. Toolbar stays visible so the user can correct it.
1<DataView.EmptyState>2 <Text>No matches for your filters.</Text>3</DataView.EmptyState>4<DataView.ZeroState>5 <Text>Nothing here yet.</Text>6</DataView.ZeroState>
Renderers return null when !hasData — siblings render the messaging.
Display Properties (column visibility)
Visibility is a single global map on context. DataView.List honours it for free (TanStack column visibility hides the grid track). For free-form renderers, wrap fields in DataView.DisplayAccess:
1<DataView.DisplayAccess accessorKey="email">2 <Text>{row.email}</Text>3</DataView.DisplayAccess>
accessorKeys not present in fields default to visible, so typos don't silently break renders.
API Reference
Root
Prop
Type
Field
Prop
Type
Sort
Prop
Type
Query
Prop
Type
View spec
Prop
Type
DataView.List
Prop
Type
Column
Prop
Type
DataView.Custom
Prop
Type
DataView.DisplayAccess
Prop
Type
DataView.EmptyState
Prop
Type
DataView.ZeroState
Prop
Type
DataView.DisplayControls
The popover housing the view switcher, Ordering, Grouping, and Display Properties. The view switcher appears at the top whenever views.length > 1. Each section can be hidden individually.
Prop
Type
Examples
Search
DataView.Search writes the input to query.search, which feeds TanStack's globalFilter — rows are filtered across every field as the user types. Drop it into the toolbar wherever you want it; in client mode no extra wiring is needed. Type a name, email, or team below to filter the rows.
1/* DataView.Search writes the input to query.search, which feeds2 TanStack's globalFilter — rows are filtered across every field as3 the user types. Try "ada", "design", or "invited". */4<DataView5 data={data}6 fields={fields}7 defaultSort={{ name: "name", order: "asc" }}8>9 <DataView.Toolbar>10 <DataView.Search placeholder="Search by name, email, team…" />11 </DataView.Toolbar>12 <DataView.List variant="table" columns={tableColumns} />13 <DataView.EmptyState>14 <Text>No people match your search.</Text>15 </DataView.EmptyState>
By default search auto-disables in the zero state (no data and no active query) and re-enables the moment the user types. Pass autoDisableInZeroState={false} to keep it always enabled, or disabled to control it yourself. In server mode, read query.search in onTableQueryChange and filter on the backend.
List variant
DataView.List ships two presentations behind one renderer. Use variant="list" for card-style rows; the default 1fr middle column with auto end columns gives you the familiar justify-between layout.
1<DataView2 data={data}3 fields={fields}4 defaultSort={{ name: "name", order: "asc" }}5>6 <DataView.Toolbar>7 <DataView.Filters />8 </DataView.Toolbar>9 <DataView.List variant="list" columns={listColumns} />10</DataView>
Multi-view
Pass views + give each renderer a name. DataView.DisplayControls hosts the view switcher at the top of its popover automatically — give each view an optional leadingIcon to show alongside its label. Query state — filters, sort, search, visibility — persists across switches.
1/* The view switcher lives inside the DisplayControls popover. Give each2 view an optional leadingIcon to show alongside its label. */3const views = [4 { value: "table", label: "Table", leadingIcon: <RowsIcon /> },5 { value: "list", label: "List", leadingIcon: <ListBulletIcon /> },6];78<DataView9 data={data}10 fields={fields}11 defaultSort={{ name: "name", order: "asc" }}12 views={views}13 defaultView="table"14>15 <DataView.Toolbar>
Empty / zero state
1<DataView2 data={data}3 fields={fields}4 defaultSort={{ name: "name", order: "asc" }}5>6 <DataView.Toolbar>7 <DataView.Filters />8 </DataView.Toolbar>9 <DataView.List variant="table" columns={tableColumns} />1011 {/* Sibling state components driven by context. */}12 <DataView.EmptyState>13 <Text>No people match your filters.</Text>14 </DataView.EmptyState>15 <DataView.ZeroState>
Custom renderer
DataView.Custom exposes the full context as a render prop. Use it for cards, kanban, gallery, map, or any non-tabular presentation. Wrap fields in DataView.DisplayAccess so the single Display Properties toggle reaches them.
1<DataView2 data={data}3 fields={fields}4 defaultSort={{ name: "name", order: "asc" }}5>6 <DataView.Toolbar>7 <DataView.Filters />8 <DataView.DisplayControls />9 </DataView.Toolbar>1011 {/* Render prop receives the full DataView context. */}12 <DataView.Custom>13 {({ data }) =>14 data.map((p) => (15 <Card key={p.id}>
Virtualized list
For large datasets, pass virtualized to DataView.List. The parent must have a fixed height — only the rows in view are rendered. Rows auto-measure after paint, so variable-height content (avatars, wrapped text, badges) just works. estimatedRowHeight is an optional hint used only until the first measurement.
1/* Parent container must have a fixed height. */2<div style={{ height: 400 }}>3 <DataView4 data={data}5 fields={fields}6 defaultSort={{ name: "name", order: "asc" }}7 >8 <DataView.Toolbar>9 <DataView.Filters />10 <DataView.DisplayControls />11 </DataView.Toolbar>12 <DataView.List13 variant="table"14 columns={tableColumns}15 virtualized
Grouping with sticky header
Group rows by any groupable field. stickyGroupHeader pins the active group label directly under the column headers while you scroll past that group's rows. Pick a different field from DisplayControls → Grouping at runtime — the wire format stays group_by: string[].
1/* Initial `group_by` is supplied via `query`. The user can pick a2 different group from DisplayControls — same wire format either way.3 The active group header sticks under the column header as the user4 scrolls past it. */5<DataView6 data={data}7 fields={fields}8 defaultSort={{ name: "name", order: "asc" }}9 query={{ group_by: ["team"] }}10>11 <DataView.Toolbar>12 <DataView.Filters />13 <DataView.DisplayControls />14 </DataView.Toolbar>15 <DataView.List variant="table" columns={tableColumns} stickyGroupHeader />
In virtualized mode, a single sticky-anchor element swaps its content as the user scrolls past each group's offset. The natural group header at the active offset is hidden so the anchor doesn't double-render the label, and the lookup uses binary search + requestAnimationFrame so the cost stays flat regardless of group count.
1/* Virtualized + grouped + sticky. A single sticky-anchor element shows2 the active group's label; its content swaps as the user scrolls past3 each group's offset. The natural group header at the active offset is4 hidden so the anchor doesn't double-render the label. */5<div style={{ height: 360 }}>6 <DataView7 data={data} // ~1500 rows8 fields={fields}9 defaultSort={{ name: "name", order: "asc" }}10 query={{ group_by: ["team"] }}11 >12 <DataView.Toolbar>13 <DataView.Filters />14 <DataView.DisplayControls />15 </DataView.Toolbar>
Loading state
While isLoading is true, DataView.List renders loadingRowCount skeleton rows at the tail. Behaviour is identical in virtualized and non-virtualized mode, and during initial load (skeletons fill the row pane) as well as during paginated server-mode load-more (skeletons render below the last loaded row).
1/* `DataView.List` renders `loadingRowCount` skeleton rows while2 `isLoading` is true. Existing rows render alongside skeletons in3 server mode (load-more). */4<DataView5 data={loadingRows}6 fields={fields}7 defaultSort={{ name: "name", order: "asc" }}8 isLoading={isLoading}9 loadingRowCount={4}10>11 <DataView.Toolbar>12 <DataView.Filters />13 </DataView.Toolbar>14 <DataView.List variant="table" columns={tableColumns} />15</DataView>
In server mode, infinite scroll triggers via a single sentinel + IntersectionObserver — there are no scroll-distance knobs to tune. While isLoading is true the sentinel is suppressed so the consumer's onLoadMore isn't fired again during a fetch.
Per-view fields override
Each renderer accepts an optional fields prop. It fully replaces the root fields for that view's active session — filter chips, sort menu, and Display Properties reflect the override while the view is active. Common pattern: spread root fields and tweak the few that differ.
1/* The List view hides Email by overriding fields on its renderer.2 Display Properties and filter chips both reflect the override. */3const listFields = fields.map((f) =>4 f.accessorKey === "email" ? { ...f, hideable: false, defaultHidden: true } : f5);67<DataView8 data={data}9 fields={fields}10 defaultSort={{ name: "name", order: "asc" }}11 views={[12 { value: "table", label: "Table" },13 { value: "list", label: "List" },14 ]}15 defaultView="table"
1const listFields = fields.map((f) =>2 f.accessorKey === "email" ? { ...f, hideable: false, defaultHidden: true } : f,3);45<DataView.List name="list" variant="list" columns={listColumns} fields={listFields} />;
Custom group buckets (groupByResolvers)
The wire format keeps group_by: string[]. To group by something that isn't a raw accessor (e.g. "by week of created_at"), supply a resolver:
1<DataView2 groupByResolvers={{3 name_first_letter: (row) => row.name.charAt(0).toUpperCase(),4 }}5 // query.group_by = ["name_first_letter"]6/>
Server mode
1<DataView2 mode="server"3 data={page.rows}4 isLoading={loading}5 totalRowCount={page.total}6 query={query}7 onTableQueryChange={(q) => setQuery(q)}8 onLoadMore={() => fetchNext()}9>10 …11</DataView>
In server mode, onTableQueryChange fires whenever the query changes. The DataView.List shows skeleton loader rows when isLoading is true. Filter predicates and sort run on the server — the local TanStack table is manual.