DataView
A unified data primitive whose query state drives swappable renderers. List, timeline, custom — 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 or server mode, and row model derivation. It doesn't draw anything itself. You pick the renderer that does, and the same <DataView> can host a table, a list, or a free-form view, switchable at runtime without losing query state.
This page covers what every renderer shares. The renderers have their own pages:
DataView.Custom is the escape hatch for everything else — cards, kanban, gallery — and is covered below.
Anatomy
Import and assemble the component:
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.ClearFilters />22</DataView>
Usage
Everything on this page applies to every renderer. Read it in order: fields and columns come first because the rest of the query model builds on them.
Fields and columns
fields is renderer-agnostic metadata declared once on the root — filter capability, sort capability, group capability, visibility, group-header presentation. Cell and header renderers live on the renderer's column spec instead, such as 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];
The split is what lets one set of filters, sorts, and toggles drive every renderer. Declare capability once on the root; declare presentation per renderer.
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.
Display properties
Column 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.
Empty and zero states
Empty and zero are computed once on context (isEmptyState, isZeroState) and exposed as sibling components.
- Zero state — no data, no active query. The first-use surface. The toolbar is hidden automatically.
- Empty state — no rows visible because filters, search, or sort exclude them all. The 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>7<DataView.ClearFilters />
Renderers return null when !hasData — the siblings render the messaging.
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>
Clear filters
When rows are hidden by filters, DataView.List automatically renders a flat footer summarising the hidden count with a Clear Filters action. That's the default treatment.
DataView.ClearFilters is a separate sibling that surfaces the same action as a bordered panel in the empty state, when a query returns no rows at all. It reads context, resets filters and search on click, and renders nothing outside the empty state. Place it once alongside your renderer, separate from DataView.EmptyState, and it manages its own visibility.
1<DataView.List variant="table" columns={tableColumns} />2<DataView.EmptyState>3 <Text>No matches for your filters.</Text>4</DataView.EmptyState>5<DataView.ClearFilters />
Multi-view
Pass views and 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 {5 value: "table",6 label: "Table",7 leadingIcon: <Rows3 size={16} strokeWidth={1.5} />,8 },9 {10 value: "list",11 label: "List",12 leadingIcon: <LayoutList size={16} strokeWidth={1.5} />,13 },14];15
Per-view fields
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. The common pattern is to spread the 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 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}>
Server mode
In client mode DataView derives everything locally: filter predicates, sort, grouping, and search all run over the data array you pass. Set mode="server" and that work moves to your backend. The local TanStack table goes manual, onTableQueryChange fires whenever the query changes, and it's on you to fetch matching rows and pass them back down.
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>
onTableQueryChangehands you the full query object whenever any part of it changes — a filter chip, a sort, a grouping choice, a keystroke in search. Translate it to your API's parameters and refetch.totalRowCountis what the "hidden by filters" footer counts against. Without it the footer can't tell how many rows your filters excluded, since the client only ever sees the current page.- Grouping still comes from the root's
groupData, so section order, labels, and counts match across renderers.
Sorting is the one place a silent mismatch is possible. Rows render in whatever order your backend returned them, so a backend that ignores the sort it was handed produces a correctly filtered view in an arbitrary order, with nothing logged to say so.
Grouping stays group_by: string[] on the wire in both modes. To group by something that isn't a raw accessor — "by week of created_at", "by first letter" — supply a resolver on the root. The resolved key is what lands in query.group_by, so your backend receives a stable string it can switch on, and the same code works unchanged in client mode.
1<DataView2 groupByResolvers={{3 name_first_letter: (row) => row.name.charAt(0).toUpperCase(),4 }}5 // query.group_by = ["name_first_letter"]6/>
Fetching itself is renderer-shaped. List pages through an infinite-scroll sentinel; Timeline fetches by visible time window instead.
API Reference
The root and the shapes it takes — fields, sort and query — followed by the sibling components that read them from context.
Root
Prop
Type
Field
Prop
Type
Sort
Prop
Type
Query
The shape query accepts and onTableQueryChange hands back. In server mode this is the contract your backend implements.
Prop
Type
View spec
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
DataView.ClearFilters
A context-driven "Clear Filters" affordance for the empty state. Place it as a sibling of your renderer, separate from DataView.EmptyState. It renders a bordered panel when a query returns no rows, and nothing otherwise. The flat footer for the data state is rendered automatically by DataView.List.
Prop
Type
Slots
Every rendered part carries a stable data-slot attribute for styling and testing. Filter chips inside DataView.Filters expose the FilterChip slots; DataView.Search exposes the Search slots. Renderer slots live on the List and Timeline pages.
Toolbar and filters
| Slot | Element |
|---|---|
data-view-toolbar | Toolbar container |
data-view-filters | Filter chip row |
data-view-add-filter | Default add-filter trigger |
data-view-add-filter-item | An entry in the add-filter menu |
data-view-filter-summary | "Items hidden by filters" footer |
data-view-filter-summary-text | Count + label group |
data-view-filter-summary-count | Hidden-row count |
data-view-filter-summary-label | Footer label text |
data-view-filter-summary-clear | The "Clear Filters" button |
Display controls
| Slot | Element |
|---|---|
data-view-display-trigger | Default "Display" trigger button |
data-view-display-content | Popover body |
data-view-display-section | A section inside the popover |
data-view-display-reset | Reset row |
data-view-display-reset-button | The "Reset to default" button |
data-view-ordering / -label / -control / -select / -direction | Ordering row and its parts |
data-view-grouping / -label / -control / -select | Grouping row and its parts |
data-view-display-properties / -label / -list / -chip | Display Properties section and its parts |
data-view-view-switcher / -tab | View switcher tabs |
States
| Slot | Element |
|---|---|
data-view-empty-state | DataView.EmptyState container |
data-view-zero-state | DataView.ZeroState container |
Accessibility
- When
onRowClickis set, each row getstabIndex={0}and activates with Enter or Space, matching a native button. Rows keep their structural role so cells stay associated with their row, and key presses bubbling up from interactive children (buttons, links in cells) are ignored so they don't also trigger row activation. - Renderer-specific semantics are documented on the List and Timeline pages.