DataView

A unified data primitive whose query state drives swappable renderers. List, custom, multi-view — 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/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";
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>

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];
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];

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

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.

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<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="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 each
2 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];
7
8<DataView
9 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<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>

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}>

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 <DataView
4 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.List
13 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 a
2 different group from DisplayControls — same wire format either way.
3 The active group header sticks under the column header as the user
4 scrolls past it. */
5<DataView
6 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 shows
2 the active group's label; its content swaps as the user scrolls past
3 each group's offset. The natural group header at the active offset is
4 hidden so the anchor doesn't double-render the label. */
5<div style={{ height: 360 }}>
6 <DataView
7 data={data} // ~1500 rows
8 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 while
2 `isLoading` is true. Existing rows render alongside skeletons in
3 server mode (load-more). */
4<DataView
5 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 } : 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 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<DataView
2 groupByResolvers={{
3 name_first_letter: (row) => row.name.charAt(0).toUpperCase(),
4 }}
5 // query.group_by = ["name_first_letter"]
6/>

Server mode

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>

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.