DataView

A unified data primitive whose query state drives swappable renderers. List, timeline, custom — one filter/sort/group/search model.

1<DataView
2 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:

  • List — table and list presentations.
  • Timeline — cards on a continuous time axis.

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";
9
10<DataView data={data} fields={fields} defaultSort={defaultSort}>
11 <DataView.Toolbar>
12 <DataView.Search />
13 <DataView.Filters />
14 <DataView.DisplayControls />
15 </DataView.Toolbar>
16
17 <DataView.List variant="table" columns={tableColumns} />
18
19 <DataView.EmptyState>{/* no matches */}</DataView.EmptyState>
20 <DataView.ZeroState>{/* no data yet */}</DataView.ZeroState>
21 <DataView.ClearFilters />
22</DataView>

Usage

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];
6
7const 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.

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 feeds
2 TanStack's globalFilter — rows are filtered across every field as
3 the user types. Try "ada", "design", or "invited". */
4<DataView
5 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<DataView
2 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} />
10
11 {/* 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 each
2 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 } : f
5);
6
7<DataView
8 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);
4
5<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<DataView
2 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
11 {/* 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<DataView
2 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>
  • onTableQueryChange hands 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.
  • totalRowCount is 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<DataView
2 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

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

SlotElement
data-view-toolbarToolbar container
data-view-filtersFilter chip row
data-view-add-filterDefault add-filter trigger
data-view-add-filter-itemAn entry in the add-filter menu
data-view-filter-summary"Items hidden by filters" footer
data-view-filter-summary-textCount + label group
data-view-filter-summary-countHidden-row count
data-view-filter-summary-labelFooter label text
data-view-filter-summary-clearThe "Clear Filters" button

Display controls

SlotElement
data-view-display-triggerDefault "Display" trigger button
data-view-display-contentPopover body
data-view-display-sectionA section inside the popover
data-view-display-resetReset row
data-view-display-reset-buttonThe "Reset to default" button
data-view-ordering / -label / -control / -select / -directionOrdering row and its parts
data-view-grouping / -label / -control / -selectGrouping row and its parts
data-view-display-properties / -label / -list / -chipDisplay Properties section and its parts
data-view-view-switcher / -tabView switcher tabs

States

SlotElement
data-view-empty-stateDataView.EmptyState container
data-view-zero-stateDataView.ZeroState container

Accessibility

  • When onRowClick is set, each row gets tabIndex={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.