A reusable React library that renders server-driven, paginated datasets as either a table or a card grid, switchable at runtime, with full feature parity between the two.
Built on Mantine v9 and TanStack Table v8.
See CHANGELOG.md
- One hook drives both a Mantine
Tableand a MantineCardgrid; switch at runtime. - Opt-in schedule presentation: project event data into a Mantine calendar (day/week/month/year), switchable alongside table and cards. Ships from a separate subpath so the scheduler dependency is never bundled unless you use it.
- Server-side pagination, sorting (including multi-sort), column filters, and global search.
- Column data types (
text,number,currency,date,boolean) with automatic Intl-based formatting. - Seven filter variants with smart controls:
SegmentedControlfor booleans,RangeSliderfor bounded numbers,DatePickerInputfor dates. - Custom filter components. Bring your own UI per column.
- Column pinning (left/right) with sticky positioning.
- CSV export with optional formatted output.
- Router-agnostic URL state sync with a default History-API adapter.
- Cross-page row selection + a shared bulk-action bar.
- Column-meta card composition (
title/subtitle/media/badge/meta) + arenderCardescape hatch. - Responsive: force-to-cards below a breakpoint, filters collapse to a bottom drawer on mobile.
- Faceted search with server-provided counts on filter options and range buckets with dynamic totals.
- External parameters (
params) for scope selectors, toggles, and other non-column filters. - Controls automatically disabled while data is loading (opt-out with
disableWhileLoading). - Loading / empty / filtered-empty / error states, consistent across both views.
- Dark mode support via Mantine's color scheme system.
- Strongly typed end to end; ships its own
.d.ts. No icon dependency.
npm install @ethanhann/mantine-dataviewnpm install react react-dom @mantine/core @mantine/dates @mantine/hooks @tanstack/react-tableThe library renders Mantine components, so your app must import Mantine's styles and wrap the tree in a provider:
import "@mantine/core/styles.css";
import "@mantine/dates/styles.css";
import "@ethanhann/mantine-dataview/styles.css"; // required for row animations
import {MantineProvider} from "@mantine/core";
<MantineProvider>{/* ... */}</MantineProvider>;The easiest path is useDataViewFetcher, which owns the fetch lifecycle for you:
import {
DataViewer,
useDataViewFetcher,
createColumnHelper,
type DataColumnDef,
} from "@ethanhann/mantine-dataview";
interface User {
id: string;
name: string;
email: string;
status: "active" | "invited";
}
const col = createColumnHelper<User>();
const columns = [
col.accessor("name", {header: "Name", meta: {card: {role: "title"}}}),
col.accessor("email", {header: "Email", meta: {card: {role: "subtitle"}}}),
col.accessor("status", {
header: "Status",
meta: {
card: {role: "badge"},
filter: {
variant: "select",
options: [
{value: "active", label: "Active"},
{value: "invited", label: "Invited"},
],
},
},
}),
] satisfies DataColumnDef<User>[];
function Users() {
const view = useDataViewFetcher<User>({
columns,
getRowId: (u) => u.id,
fetcher: async (request) => {
const res = await fetch(`/api/users?${toParams(request)}`);
const json = await res.json();
return {rows: json.items, rowCount: json.total};
},
});
return <DataViewer view={view}/>;
}<DataViewer view={view} /> renders the toolbar, the active presentation, and pagination.
The fluent col<T>() builder reduces column definition verbosity. Each method sets
dataType, filter, align, and meta.label with sensible defaults for the type:
import {col} from "@ethanhann/mantine-dataview";
const columns = col<User>()
.text("name", {card: "title"})
.text("email", {card: "subtitle", filter: false})
.currency("salary", {card: "meta"})
.number("age", {card: "meta", filter: {min: 18, max: 100}})
.boolean("active", {card: "badge"})
.date("createdAt", {card: "meta"})
.select("role", {
options: [{value: "admin", label: "Admin"}, {value: "user", label: "User"}],
card: "badge",
})
.build();| Method | dataType |
filter |
align |
|---|---|---|---|
.text(field) |
text |
text |
left |
.number(field) |
number |
numberRange |
right |
.currency(field) |
currency |
numberRange |
right |
.date(field) |
date |
dateRange |
left |
.boolean(field) |
boolean |
boolean |
left |
.select(field, { options }) |
— | select |
left |
.multiselect(field, { options }) |
— | multiselect |
left |
.custom(colDef) |
— | — | — |
Headers are auto-humanized from field names (createdAt → "Created At",
first_name → "First Name"). Override with { header: "Custom Label" }.
Options: header, card (role shorthand), cardOrder, filter (false to disable,
or object to merge), format, align, cell, enableSorting, width.
Set width (pixels) on columns that need a fixed size. Columns without a width share
the remaining space equally:
col<User>()
.text("name") // shares remaining space
.text("status", {width: 120}) // fixed 120px
.number("age", {width: 80}) // fixed 80px
.build();With raw column defs, use TanStack's size property:
col.accessor("status", {header: "Status", size: 120});Compose your own layout by passing children:
<DataViewer view={view}>
<DataViewer.Toolbar/>
<DataViewer.BulkActions/>
<DataViewer.Body/>
<DataViewer.Pagination/>
</DataViewer>Or use the standalone components directly for full control:
<DataToolbar view={view} showSearch showFilters/>
<DataTable view={view} striped highlightOnHover/>
<DataPagination view={view}/>Inject controls into the toolbar without rebuilding it from scratch using leftSection
and rightSection:
<DataViewer.Toolbar
leftSection={<Text fw={600}>Users</Text>}
rightSection={
<Group gap="xs">
<Button size="xs" onClick={() => view.exportCsv()}>Export</Button>
<Button size="xs" onClick={() => view.refetch()}>Refresh</Button>
</Group>
}
/>leftSectionrenders before the search input (start of the left group).rightSectionrenders after the view switcher (end of the right group).
Both sections are disabled during loading along with the other toolbar controls.
The ViewSwitcher is exported for standalone use with customizable labels:
import {ViewSwitcher} from "@ethanhann/mantine-dataview";
// Default
<ViewSwitcher view={view}/>
// Custom labels (text or icons)
<ViewSwitcher view={view} tableLabel="List" cardsLabel="Grid"/>
<ViewSwitcher view={view} tableLabel={<IconList/>} cardsLabel={<IconGrid/>}/>Or drive the view programmatically:
view.setView("cards"); // switch to cards
view.view; // current view: "table" | "cards" (plus any registered views, e.g. "schedule")useDataViewFetcher is a thin convenience wrapper. The core, useDataView, is fully controlled.
You supply rows/rowCount/status and respond to onRequestChange:
const [resp, setResp] = useState({rows: [], rowCount: 0});
const [status, setStatus] = useState<Status>("idle");
const view = useDataView<User>({
columns,
getRowId: (u) => u.id,
rows: resp.rows,
rowCount: resp.rowCount,
status,
onRequestChange: async (request) => {
setStatus("loading");
try {
setResp(await myApi.list(request));
setStatus("success");
} catch {
setStatus("error");
}
},
});The request is emitted immediately for pagination/sorting and debounced for search/filters.
Pass arbitrary parameters that aren't tied to a column. They're included in every
DataViewRequest, trigger a refetch when they change, and reset pagination to page 1:
const [tenantId, setTenantId] = useState("acme");
const [showArchived, setShowArchived] = useState(false);
const view = useDataViewFetcher<User>({
columns,
getRowId,
fetcher: async (request) => {
// request.params = { tenantId: "acme", showArchived: false }
const res = await api.list(request);
return {rows: res.items, rowCount: res.total};
},
params: {tenantId, showArchived},
});
// Render your own controls...
<Select data={tenants} value={tenantId} onChange={setTenantId}/>
<Switch checked={showArchived} onChange={(e) => setShowArchived(e.currentTarget.checked)}/>Values are typed as FilterParam (string | number | boolean | null | undefined | Date | string[] | number[] | Array<string | number>). A value of undefined means "omit this param".
For cases where external state affects the fetcher but isn't a named parameter (e.g. it's
baked into the closure), use deps to trigger a refetch:
const view = useDataViewFetcher<User>({
columns,
getRowId,
fetcher,
deps: [selectedTenantId],
});When any value in deps changes, the current request is re-emitted to the fetcher.
Prefer params when the server needs to see the values; use deps when they're already
in the fetcher closure.
Re-fetch the current data without changing any state:
<Button onClick={() => view.refetch()}>Refresh</Button>This re-emits the current request to the fetcher. It's the same mechanism the built-in error retry button uses.
When a sibling library (e.g. a detail panel) saves, creates, or deletes a record, the list can reflect the change instantly without a full reload. Three primitives on the return value apply in-place mutations, then schedule a background revalidation fetch that reconciles with server truth.
const view = useDataViewFetcher({...});
// A record was updated, replace it in place.
view.patchRow(updatedRecord);
// A new record was created, prepend it to the current page.
view.insertRow(newRecord);
// A record was deleted, remove it from the current page.
view.removeRow(recordId);Each call immediately updates the rendered rows and then kicks off a debounced background
refetch (default 1 second, configurable via revalidateDelay). Rapid mutations coalesce
into a single fetch. When the server responds, its data fully replaces the optimistic state,
correcting sort position, filter membership, pagination counts, and facet buckets. If that
background revalidation fails, the optimistic data is kept (the failure means it couldn't be
re-confirmed, not that your write failed) — status stays success and no error is surfaced.
view.isRevalidating is true while the background fetch is in flight. Use it to show a
subtle sync indicator without replacing content with loading skeletons.
const view = useDataViewFetcher({
fetcher,
columns,
getRowId: (row) => row.id,
revalidateDelay: 500, // default 1000ms
});
{
view.isRevalidating && <Loader size="xs"/>
}Semantics. The client cannot reproduce server-side filter membership, sort order, or facet counts. The optimistic patch is a best-effort visual preview. The background revalidation is the source of truth. An edited row that no longer matches the active filter will disappear when the server responds. A created row that doesn't belong on the current page will be repositioned. This is the stale-while-revalidate pattern: instant perceived speed with eventual correctness.
When using the raw useDataView hook (without the fetcher wrapper), the three methods fall
back to calling refetch(), since you control the row data externally.
Set dataType on a column's meta to enable automatic value formatting. When no explicit
cell renderer is provided, the library formats values using Intl.NumberFormat or
Intl.DateTimeFormat based on the data type. Raw values are preserved for server requests,
sorting, and filtering. Formatting is display-only.
col.accessor("price", {
header: "Price",
meta: {dataType: "currency", align: "right"},
});
col.accessor("createdAt", {
header: "Created",
meta: {dataType: "date"},
});| Data type | Default format | Example |
|---|---|---|
text |
String(value) |
"hello" |
number |
Intl.NumberFormat |
1,234 |
currency |
Intl.NumberFormat with currency |
$1,234.56 |
date |
Intl.DateTimeFormat |
Jun 2, 2026 |
boolean |
"Yes" / "No" |
Yes |
- Library defaults, the built-in formatters per data type (above).
- Table-scoped,
formatDefaultson the hook options, keyed by data type. - Column-scoped,
formatonColumnMeta, overrides everything for that column.
const view = useDataViewFetcher({
columns,
getRowId,
fetcher,
// All dates in this table use short format, currency is EUR
formatDefaults: {
date: {dateStyle: "short"},
currency: {currency: "EUR"},
},
});
// This column overrides the table default
col.accessor("createdAt", {
header: "Created",
meta: {
dataType: "date",
format: {dateStyle: "long"},
},
});
// Or use a function for full control
col.accessor("revenue", {
header: "Revenue",
meta: {
dataType: "currency",
format: (v) => `€${(v as number).toFixed(0)}`,
},
});If you provide your own cell renderer on a column, it takes full precedence over dataType
formatting.
Columns are sortable by default via table header clicks. The request.sorting array is sent
to the server so it can apply the sort.
Hold Shift and click additional column headers to add secondary, tertiary, etc. sort keys. The header shows a small index number (2, 3, ...) next to the sort icon for secondary sorts.
The toolbar's sort control drives the primary sort. Multi-sort is available through table headers only.
Disable sorting on a specific column:
col.accessor("avatar", {header: "Avatar", enableSorting: false});Column headers support the same render function pattern as cells. Pass a component or
function to the header property:
col.accessor("revenue", {
header: () => (
<Group gap={4}>
<IconCurrencyDollar size={14}/>
<span>Revenue</span>
</Group>
),
meta: {align: "right"},
});Cell renderers can check whether they're rendering in table or card view using getViewMode:
import {getViewMode} from "@ethanhann/mantine-dataview";
col.accessor("name", {
cell: (ctx) => {
const mode = getViewMode(ctx); // "table" | "cards"
return mode === "cards" ? <Text>{ctx.getValue()}</Text> : <Anchor>{ctx.getValue()}</Anchor>;
},
});getViewMode accepts a cell context, header context, or table instance. It updates
automatically when the user toggles the view.
Export the current page's visible columns as a CSV file:
<Button onClick={() => view.exportCsv()}>Export CSV</Button>
// With options
view.exportCsv({filename: "users.csv", separator: ";"});
// Export formatted values instead of raw data
view.exportCsv({formatted: true});Cell values are sanitized against spreadsheet formula injection by default — values that begin
with =, +, -, or @ are prefixed with a single quote so they aren't evaluated as formulas
when the file is opened. Disable it with view.exportCsv({sanitizeFormulas: false}) only when the
output is consumed by a non-spreadsheet parser. Object and array cell values are serialized as JSON.
The exportCsv function is also available as a standalone utility:
import {exportCsv} from "@ethanhann/mantine-dataview";
exportCsv(view.table, {filename: "report.csv"});view.exportJson() (and the standalone exportJson) downloads the current page's visible
columns as a JSON array of objects keyed by column id, with raw values:
view.exportJson({filename: "users"});Client-side export covers the current page only, since the client never holds the full set.
For export-all, view.exportRequest is the current request without pagination: everything the
server needs to reproduce the full result set (sort, filters, search, params). Hand it to a
backend export endpoint:
<Button onClick={() => api.downloadCsv(view.exportRequest)}>Export all</Button>Enable drag handles on the table's header edges with enableColumnResizing:
const view = useDataViewFetcher<User>({
columns,
getRowId,
fetcher,
enableColumnResizing: true,
});- Drag a header's right edge to resize; double-click the handle to reset that column.
- User widths live in
state.columnSizingkeyed by column id. Persist and restore them viainitialState: { columnSizing: savedWidths }. - With resizing enabled every column carries a concrete width (TanStack's default is 150px);
set
widthon columns that should start wider or narrower. - Opt a column out with TanStack's
enableResizing: falseon its def. - Resizing is pointer-driven (mouse and touch). The handles are not in the tab order.
- Column widths are intentionally not URL-synced, like visibility and pinning.
The Columns dropdown includes move up/down buttons next to each column, driving TanStack's
columnOrder state. Seed an order via initialState: { columnOrder: ["status", "name"] } or
programmatically with view.table.setColumnOrder([...]). The order persists with the
preference persistence adapter. Drag-to-reorder is not offered.
Pin columns to the left or right edge so they stay visible while scrolling horizontally.
const view = useDataViewFetcher<User>({
columns,
getRowId,
fetcher,
initialState: {
columnPinning: {left: ["name"], right: ["actions"]},
},
});The Columns dropdown in the toolbar includes pin toggle buttons (left/right) next to each column's visibility checkbox. Clicking a pin button freezes that column to the corresponding edge; clicking it again unpins.
view.table.getColumn("name")?.pin("left");
view.table.getColumn("name")?.pin(false); // unpinDefine filters declaratively on column meta. Seven variants are built in:
| Variant | Control | Notes |
|---|---|---|
text |
TextInput |
Free-text search |
select |
Select (dropdown) |
Single choice, clearable |
multiselect |
MultiSelect |
Multiple choices |
boolean |
SegmentedControl (All/Yes/No) |
One-click toggle |
numberRange |
RangeSlider or two NumberInputs |
Slider when min/max are set |
date |
DatePickerInput |
Calendar picker |
dateRange |
DatePickerInput (range) |
Two-date calendar picker |
// Boolean, renders as a segmented control
meta: {filter: {variant: "boolean"}}
// Number range with slider
meta: {filter: {variant: "numberRange", min: 0, max: 1000, step: 10}}
// Number range without bounds (falls back to two number inputs)
meta: {filter: {variant: "numberRange"}}
// Date range
meta: {filter: {variant: "dateRange"}}Load select/multiselect options from the server with loadOptions. It is called with the
empty query on mount and with the debounced search text as the user types (the control becomes
searchable automatically):
col.accessor("city", {
header: "City",
meta: {
filter: {
variant: "select",
loadOptions: async (query) => {
const res = await fetch(`/api/cities?q=${encodeURIComponent(query)}`);
return res.json(); // FilterOption[]: { value, label }[]
},
},
},
});Facet-provided options still win while present, since they carry live counts. A failed load keeps the last options shown.
For filters that don't fit the built-in variants, provide a component instead:
import type {CustomFilterComponentProps} from "@ethanhann/mantine-dataview";
function LocationFilter({value, onChange}: CustomFilterComponentProps) {
return (
<Chip.Group value={(value as string) ?? ""} onChange={(v) => onChange(v || undefined)}>
<Group gap={4}>
<Chip value="london" size="xs">London</Chip>
<Chip value="berlin" size="xs">Berlin</Chip>
</Group>
</Chip.Group>
);
}
col.accessor("location", {
header: "Location",
meta: {filter: {component: LocationFilter}},
});FilterControl is exported so you can place individual filters anywhere in your layout:
import {FilterControl} from "@ethanhann/mantine-dataview";
<DataViewer view={view}>
{view.table.getColumn("inStock") && (
<FilterControl column={view.table.getColumn("inStock")!}/>
)}
<DataViewer.Toolbar/>
<DataViewer.Body/>
<DataViewer.Pagination/>
</DataViewer>Reset all filters or clear a specific column from anywhere, no need to be inside the toolbar:
// Reset all filters
<Button onClick={() => view.resetAllFilters()}>Reset all filters</Button>
// Clear a single column's filter
<Button onClick={() => view.resetFilter("status")}>Clear status filter</Button>- Desktop, few filters (at or below
filterInlineThreshold, default 3): rendered inline in the toolbar. - Desktop, many filters: collapsed into a "Filters" popover button with active count badge.
- Mobile (below
smbreakpoint): always collapsed into a bottom drawer. - A "Reset filters" button appears automatically when any filter is active.
When the server returns facets in the response, filter controls automatically adapt to show
dynamic counts, disable zero-result options, and render clickable range buckets.
fetcher: async (request) => {
const res = await api.list(request);
return {
rows: res.items,
rowCount: res.total,
facets: {
size: {
type: "values",
values: [
{value: "S", label: "Small", count: 12},
{value: "M", label: "Medium", count: 34},
{value: "L", label: "Large", count: 0},
],
},
price: {
type: "ranges",
ranges: [
{label: "Under $25", from: 0, to: 25, count: 15},
{label: "$25-$50", from: 25, to: 50, count: 28},
{label: "$50+", from: 50, to: 999, count: 7},
],
min: 5,
max: 249,
},
},
};
};| Filter type | Without facets | With value facets | With range facets |
|---|---|---|---|
| Select | Static options | Options with counts, zero-count dimmed | - |
| Boolean | All / Yes / No | All / Yes (12) / No (3) | - |
| Number range | Slider or inputs | Slider (bounds from facet) | Clickable range buckets + slider |
| Date range | Date picker | Date picker | Clickable range buckets + picker |
Facets are optional and backward compatible. Facet data updates on every fetch, creating the classic faceted search loop where filtering one dimension updates counts on all others.
// Discrete values - for select, multiselect, boolean filters
type ValueFacet = {
type: "values";
values: { value: string; label?: string; count: number }[];
};
// Bucketed ranges - for numberRange, dateRange filters
type RangeFacet = {
type: "ranges";
/** Optional: whether bounds are numbers or ISO date strings, so consumers needn't guess. */
kind?: "number" | "date";
ranges: { label: string; from: number | string; to: number | string; count: number }[];
min?: number | string;
max?: number | string;
};Return a summary object from the server (keyed by column id, raw values) and the table
renders it as a footer row while the card grid shows a summary block. Values format by the
column's dataType, like cells:
fetcher: async (request) => {
const res = await api.list(request);
return {
rows: res.items,
rowCount: res.total,
summary: {salary: res.totals.salary, age: res.totals.avgAge},
};
};Only visible columns with a summary entry render. Like facets, the data updates on every
fetch, so aggregates reflect the active filters. view.summary exposes the raw record for
custom presentations.
In card view, each visible column is placed by its meta.card.role:
| role | rendered as |
|---|---|
title |
card heading |
subtitle |
dimmed line under the title |
media |
full-bleed top section |
badge |
inline badge |
meta |
label / value pair |
hidden |
omitted |
Hiding a column via the toolbar hides both its table cell and its card field. Within each
role group, columns are ordered by meta.card.order.
For full control over card content, use renderCard:
<DataViewer
view={view}
renderCard={({data, selected, toggleSelected}) => (
<Card withBorder padding="md" onClick={toggleSelected}>
<Text fw={700}>{data.name}</Text>
<Text size="sm" c="dimmed">{data.email}</Text>
{selected && <Badge>Selected</Badge>}
</Card>
)}
/>To keep the default composition but wrap it in a custom card shell, use the Card slot:
<DataViewer
view={view}
slots={{
Card: ({data, selected, children}) => (
<Card
withBorder
padding="lg"
style={{background: selected ? "var(--mantine-color-blue-light)" : undefined}}
>
{children}
</Card>
),
}}
/>Provide a BulkActions slot to add actions when rows are selected:
<DataViewer
view={view}
slots={{
BulkActions: (selection) => (
<Button
color="red"
variant="light"
onClick={() => {
deleteUsers(selection.ids);
selection.clear();
}}
>
Delete {selection.count}
</Button>
),
}}
/>The selection object exposes the current selection and methods to change it.
Read the selection with count, ids (every selected row id across pages), and pageRows (selected
row data on the current page).
The older rows alias still works and equals pageRows.
Change the selection programmatically with select, deselect, toggle, set, clear, and
isSelected.
All are keyed by getRowId and span pages, so you can select a row that is not on the current page.
select and deselect accept a single id or an array.
view.selection.select("42");
view.selection.select(["7", "9"]);
view.selection.toggle("3");
view.selection.set(["1", "2"]); // replace the whole selection
view.selection.clear();
const isOn = view.selection.isSelected("42");Pass enableMultiRowSelection={false} to useDataView for single-select, where these methods collapse
to a single id.
The table and card views support keyboard navigation, on by default.
A roving focus point moves through the rows or cards with the arrow keys, and the body is exposed as a
role="grid" with aria-selected items.
| Key | Action |
|---|---|
| Arrow keys | Move the focused row or card |
| Home / End | Jump to the first or last item |
| Space | Toggle selection on the focused item |
| Shift and an arrow | Extend a contiguous selection from the anchor |
The table navigates one row at a time.
The card grid navigates in two dimensions, following the rendered layout.
It reads the real card geometry, so any responsive cols value works, and it falls back to left and
right traversal when the layout reports no rows (for example during tests).
Selected rows and cards get a highlighted background, and the active item shows a focus ring on both
click and keyboard focus.
These styles ship in the package stylesheet, so import @ethanhann/mantine-dataview/styles.css in your
app entry.
Selection from the keyboard uses the same state as the checkboxes and the bulk-action bar.
Set keyboardNavigation={false} on DataTable or DataCards to opt out, for instance when embedding a
view inside your own keyboard model.
<DataViewer view={view}>
<DataViewer.Body tableProps={{keyboardNavigation: false}}/>
</DataViewer>;Pass onRowActivate to DataTable or onCardActivate to DataCards to handle "opening" an item.
The handler receives the typed row and fires on Enter when the item is focused, or on a single click of
its body.
Clicks on the selection checkbox, on links or buttons inside the item, or while the user is selecting
text, do not activate, so those keep working.
<DataViewer view={view}>
<DataViewer.Body
tableProps={{onRowActivate: (booking) => openDetail(booking)}}
cardsProps={{onCardActivate: (booking) => openDetail(booking)}}
/>
</DataViewer>;Activation is part of the keyboard layer, so it requires keyboardNavigation (the default). Selection
with Space and the checkbox is independent of activation.
A custom table row (the Row slot) joins the grid by spreading the provided rowProps onto its
element, so roving focus, selection, and activation all work. The value is empty when navigation is
off, so spreading it is always safe.
<DataTable
view={view}
slots={{
Row: ({cells, rowProps}) => <Table.Tr {...rowProps}>{cells}</Table.Tr>,
}}
/>;Override loading, empty, and error states:
<DataViewer
view={view}
slots={{
Empty: () => <Text>No users found.</Text>,
ErrorState: ({retry}) => (
<Stack align="center">
<Text c="red">Something went wrong.</Text>
<Button onClick={retry}>Retry</Button>
</Stack>
),
LoadingTable: () => <MySkeleton/>,
LoadingCards: () => <MyCardSkeleton/>,
}}
/>A filtered-empty state is handled automatically. It shows a "clear filters" action so users can reset without manually removing each filter.
Every built-in UI string is overridable through the labels option, merged over the English
defaults. Plain strings cover static text and functions cover parameterized text:
const view = useDataViewFetcher<User>({
columns,
getRowId,
fetcher,
labels: {
searchPlaceholder: "Suchen…",
columns: "Spalten",
noResults: "Keine Ergebnisse.",
selectedCount: (count) => `${count} ausgewählt`,
paginationRange: (start, end, total) => `${start} bis ${end} von ${total}`,
},
});The resolved dictionary is exposed as view.labels, and every component reads from it: toolbar,
filter controls, sort and column menus, view switcher, selection checkboxes, bulk-action bar,
state messages, pagination, and the schedule navigators. See the DataViewLabels type for the
full key list; DEFAULT_LABELS exports the English defaults.
Notes:
- Explicit per-component string props (such as the toolbar's
searchPlaceholderor the pager'spageSizeLabel) still win over the dictionary. - Cell values already localize through
IntlviadataTypeformatting and theformat/formatDefaultspipeline; boolean cell text is customized with aformatfunction. - The standalone
FilterControldefaults to English. Passlabels={view.labels}when placing it outside the toolbar.
Persist the user's layout choices (column visibility, pinning, sizing, and page size) across sessions with a storage adapter:
import {localStorageAdapter} from "@ethanhann/mantine-dataview";
const persist = useMemo(() => ({adapter: localStorageAdapter("users-table")}), []);
const view = useDataViewFetcher<User>({columns, getRowId, fetcher, persist});- Hydration order on mount: defaults, then
initialState, then storage, then the URL. An explicit URL always wins over a stored preference. - Writes are debounced (250ms), so a resize drag lands as one write.
- The page index, sort, filters, search, and selection are deliberately not persisted. The URL is their share-and-restore mechanism.
- Restrict what persists with
persist.include, e.g.["columnVisibility", "columnSizing"]. - A stored page size acts as the effective default, so it stays out of clean URLs.
- Bump the storage key (e.g.
"users-table.v2") when your column set changes incompatibly; malformed or stale values are dropped field by field.
Implement StateStorageAdapter (read, write, optional subscribe for cross-tab updates)
to store preferences elsewhere, such as per-user settings on your server. The built-in
localStorageAdapter signals cross-tab changes automatically.
Router-agnostic. The default adapter uses the History API; memoize it once:
import {windowHistoryAdapter} from "@ethanhann/mantine-dataview/url";
const adapter = useMemo(() => windowHistoryAdapter(), []);
const view = useDataViewFetcher<User>({
columns,
getRowId,
fetcher,
urlSync: {adapter},
});State round-trips through the query string
(?page=2&size=25&sort=name:asc&q=ada&view=cards&f.status=active) and stays in sync with
browser back/forward.
To integrate with a router, implement these three methods:
interface UrlStateAdapter {
/** Current query params as a flat record. */
read(): Record<string, string>;
/** Write the next params; `replace` controls history entry vs push. */
write(next: Record<string, string>, opts?: { replace?: boolean }): void;
/** Optional: notify on external nav (back/forward). Returns an unsubscribe fn. */
subscribe?(onChange: () => void): () => void;
}Always memoize the adapter so the sync effects don't re-bind every render.
Without
subscribe, back and forward navigation changes the URL but the view never re-reads it, so the table silently desyncs. Implement it (apopstatelistener suffices) unless your app never relies on history navigation.
import {useSearchParams} from "react-router-dom";
import type {UrlStateAdapter} from "@ethanhann/mantine-dataview/url";
function useReactRouterAdapter(): UrlStateAdapter {
const [, setSearchParams] = useSearchParams();
return useMemo<UrlStateAdapter>(
() => ({
read: () => Object.fromEntries(new URLSearchParams(window.location.search)),
write: (next, opts) => setSearchParams(next, {replace: opts?.replace}),
subscribe: (onChange) => {
window.addEventListener("popstate", onChange);
return () => window.removeEventListener("popstate", onChange);
},
}),
[setSearchParams],
);
}import {useNavigate} from "@tanstack/react-router";
import type {UrlStateAdapter} from "@ethanhann/mantine-dataview/url";
function useTanStackRouterAdapter(): UrlStateAdapter {
const navigate = useNavigate();
return useMemo<UrlStateAdapter>(
() => ({
read: () => Object.fromEntries(new URLSearchParams(window.location.search)),
write: (next, opts) => navigate({search: () => next, replace: opts?.replace}),
subscribe: (onChange) => {
window.addEventListener("popstate", onChange);
return () => window.removeEventListener("popstate", onChange);
},
}),
[navigate],
);
}- Restrict which slices sync with
urlSync.include(e.g. only pagination and sorting). - Override param names or codecs with
urlSync.serialize. - Choose how writes affect history with
urlSync.historyMode:"replace"(default) keeps a clean history so the back button doesn't replay each filter/sort/page change;"push"creates a new entry per change so back/forward steps through them. In push mode, search and filter changes are coalesced (300ms) so a typing burst lands as one history entry. - URLs are kept clean: the page param is omitted on page 1, and the size and view params are
omitted while they equal their defaults (honoring
initialState). - Selection, column visibility, and column pinning are not URL-synced by design.
Force the card view below a breakpoint:
const view = useDataViewFetcher<User>({
columns,
getRowId,
fetcher,
responsive: {forceCardsBelow: "sm", lockSwitcherOnMobile: true},
});
<DataViewer view={view} lockSwitcherOnMobile/>;When forceCardsBelow is set and the viewport is below that breakpoint:
- The view is forced to cards regardless of the user's choice.
- The user's explicit choice is preserved and restored above the breakpoint.
- The view switcher is disabled (or hidden entirely with
lockSwitcherOnMobile). - Filters always open in a bottom drawer on mobile.
By default, filter controls, sort controls, and column visibility/pinning menus are disabled while data is loading. Sort headers in the table also become non-interactive, with a dimmed appearance showing the current sort state. The search input stays enabled so users can keep typing during debounced search.
Opt out per component:
<DataTable view={view} disableWhileLoading={false}/>
<DataToolbar view={view} disableWhileLoading={false}/>By default every request change swaps the content for loading skeletons. With
keepPreviousData, a refetch keeps the previous rows on screen (status stays "success")
while the new page loads, and view.isFetching signals the fetch in flight:
const view = useDataViewFetcher<User>({
columns,
getRowId,
fetcher,
keepPreviousData: true,
});The first fetch still shows skeletons (there is nothing to keep), and a failed refetch still
shows the error state. The toolbar automatically shows a small loader while a background fetch
is in flight with data on screen; opt out with showSyncIndicator={false} on the toolbar.
view.isFetching is true during any fetch, unlike status: "loading", which only covers
fetches that replace the content.
The fetcher receives an AbortSignal that fires when the request is superseded by a newer one,
by an optimistic mutation, or by unmount. Pass it to fetch to cancel the wire request:
fetcher: async (request, {signal}) => {
const res = await fetch(`/api/users?${toParams(request)}`, {signal});
return toResponse(await res.json());
},Ignoring the signal is safe. Stale responses are discarded either way, so this only saves bandwidth and server work.
Instead of skeleton loading, rows can animate in and out with CSS transitions. New rows fade and slide in, removed rows fade out, and unchanged rows stay in place:
<DataViewer view={view} animateRows/>Be sure the CSS file from the package is included alongside Mantine's CSS:
import "@ethanhann/mantine-dataview/styles.css";When animateRows is enabled:
- Previous rows stay visible while new data loads (no skeleton flash).
- New rows enter with a slide-down fade-in animation (200ms).
- Removed rows fade out (150ms) before being removed from the DOM.
- Works in both table and card views.
This is opt-in. The default behavior (skeleton loading) is unchanged.
Event-shaped data (anything with a start time, and usually an end or duration) has a third
projection: a calendar. It's opt-in and ships from a separate subpath so the scheduler
dependency stays out of everyone else's bundle — if you don't import
@ethanhann/mantine-dataview/schedule, you pay nothing for it.
The schedule presentation wraps @mantine/schedule,
which are optional peer dependencies (along with dayjs):
npm install @mantine/schedule dayjsImport its styles after Mantine's, in order:
import "@mantine/core/styles.css";
import "@mantine/dates/styles.css";
import "@mantine/schedule/styles.css";
import "@ethanhann/mantine-dataview/styles.css";Register the schedule view; it adds a "Schedule" option to the view switcher and renders the calendar when active. Table, cards, and schedule are then switchable at runtime, driven by the same hook:
import {DataViewer} from "@ethanhann/mantine-dataview";
import {scheduleView} from "@ethanhann/mantine-dataview/schedule";
<DataViewer view={view} views={[scheduleView<Booking>()]}/>;Or render the calendar standalone:
import {DataSchedule} from "@ethanhann/mantine-dataview/schedule";
<DataSchedule view={view}/>;The same event data projects into two more presentations, each a registered view alongside the calendar. Register any subset; the switcher shows Table / Cards / Calendar / Agenda / Resources, and switching among the schedule-family views reuses the loaded window (no refetch).
import {scheduleView, agendaView, resourcesView} from "@ethanhann/mantine-dataview/schedule";
<DataViewer view={view} views={[scheduleView(), agendaView(), resourcesView()]}/>;- Agenda (
agendaView/DataAgenda) — a date-grouped list over the visible window. Mantine'sAgendaViewhas no navigation of its own, soDataAgendarenders aDataAgendaNavheader by default (prev / today / next and a day/week/month range, no year; setwithNav={false}to supply your own vialeftSection). - Resources (
resourcesView/DataResourceSchedule) — one row per resource. The rows are derived from theresource-role column's filter options; pass an explicitresourcesprop (ScheduleResourceData[]) to control labels, colors, or order. Resource views have only day/week/month levels — ayearwindow is clamped tomonth. When the response includes a value facet for the resource column, each row shows a live count (e.g. "Aspen (12)"); the rows stay stable as you filter, only the counts change. Opt out withshowResourceCounts={false}. Pass agroupsprop (ScheduleResourceGroup[]) to render a rowspan column grouping resources (e.g. rooms into wings) — it's applied across all levels.
To show the agenda inside the calendar instead of as a separate view, enable Mantine's built-in
toggle: <DataSchedule scheduleProps={{withAgenda: true}}/>.
There is no responsive forceScheduleBelow — that fallback is specific to cards (a dense table is
unusable on small screens, so cards take over). To make the calendar the only view, lock the
DataViewer to it: register the schedule view, open directly on it with scheduleInitialState(),
and hide the switcher. Compose the layout manually so you can pass showViewSwitcher={false}:
import {scheduleInitialState, scheduleView} from "@ethanhann/mantine-dataview/schedule";
// Seeds both the view and the initial window, so the first fetch is already windowed (no double
// fetch). Memoize it so the anchor date is fixed to first render.
const initialState = useMemo(() => scheduleInitialState("week"), []);
const view = useDataViewFetcher<Booking>({columns, getRowId, fetcher, initialState});
<DataViewer view={view} views={[scheduleView<Booking>()]}>
<DataViewer.Toolbar showViewSwitcher={false}/> {/* no table/cards toggle */}
<DataViewer.BulkActions/>
<DataViewer.Body/>
<DataViewer.Pagination/> {/* auto-suppressed in schedule mode */}
</DataViewer>;The toolbar's search and filters still apply across the calendar; there is no table/cards toggle and
no pager. (For just the calendar with no toolbar at all, use the standalone <DataSchedule> above.)
Without scheduleInitialState, opening on the calendar still works — the calendar sets its own
window on mount — but it costs one extra fetch (a window-less request, then a windowed one). Seeding
the window avoids that. A window set while the view is not the schedule view is held but never sent
to the fetcher, so table and card requests are never polluted by a stale range.
The fetched window aligns to the same week start the calendar grid renders.
Mantine defaults to Monday, and the schedule views read firstDayOfWeek from your DatesProvider, so
setting it in one place keeps the grid and the request in agreement with no extra prop.
<DatesProvider settings={{firstDayOfWeek: 0}}> {/* Sunday */}
<DataSchedule view={view}/>
</DatesProvider>;scheduleInitialState runs before render, so it cannot read the provider.
Pass firstDayOfWeek to it as well when your DatesProvider overrides the default, otherwise the
first seeded window is Monday-aligned until the first navigation:
scheduleInitialState("week", new Date(), "schedule", 0).
Slot custom controls into a header row above the calendar with leftSection and rightSection —
the schedule analog of the toolbar's sections. The header persists across the loading, error, and
empty states.
<DataSchedule
view={view}
leftSection={<Text fw={600}>Team calendar</Text>}
rightSection={
<Group gap="xs">
<Button size="xs" variant="default" onClick={() => view.refetch()}>Refresh</Button>
<Button size="xs" onClick={createEvent}>New event</Button>
</Group>
}
/>;leftSectionrenders at the start of the header row,rightSectionat the end.- They also flow through
scheduleView({ leftSection, rightSection })for the integratedDataViewer. - Unlike the toolbar's sections, these are not auto-disabled while loading — read
view.statusorview.isRevalidatingif you want to disable your own controls.
Rows become calendar events the same way columns become card fields — declaratively, via
meta.schedule roles (or the col builder's schedule shorthand):
| role | supplies |
|---|---|
start |
event start (required). A Date, ISO string, or epoch ms. |
end |
event end. Mutually exclusive with duration. |
duration |
event length — minutes (number) or ISO-8601 ("PT1H30M"). Derives end. |
title |
event label. |
color |
a Mantine color or CSS color. |
resource |
resource/group id, used by the resources view to place events in rows. |
allDay |
boolean all-day flag, carried on the event payload. Mantine's event shape has no all-day lane, so it has no visual effect unless a custom renderEventBody reads payload.allDay. |
import {col} from "@ethanhann/mantine-dataview";
const columns = col<Booking>()
.text("title", {schedule: "title"})
.date("start", {schedule: "start"})
.date("end", {schedule: "end"}) // or .number("mins", { schedule: "duration" })
.select("status", {
schedule: {role: "color", map: (s) => STATUS_COLOR[s]},
options: statusOptions,
})
.build();The map transform handles values that aren't the event value directly — a status mapped to a
color, or an epoch number mapped to a Date.
For event shapes that aren't column-backed, use the toEvent escape hatch, which returns a
@mantine/schedule event directly and bypasses role composition. The event id must match the
row's getRowId output, or click-to-select, onEventClick, and the editing callbacks cannot
resolve the row:
<DataSchedule
view={view}
toEvent={(b) => ({
id: b.id,
title: b.title,
start: new Date(b.start),
end: new Date(b.end),
color: STATUS_COLOR[b.status],
})}
/>;All three presentations take an onEventClick(row, event, nativeEvent) that hands you the typed
original row (plus the Mantine event and the DOM event):
<DataSchedule
view={view}
onEventClick={(booking) => openDetail(booking)} // `booking` is your row type
/>;Same prop on DataAgenda and DataResourceSchedule. By default (no handler) clicking an event
toggles that row's selection, feeding the bulk-action bar. Providing onEventClick replaces that
default; to keep selection as well, call the exported helper inside your handler:
import {toggleEventSelection} from "@ethanhann/mantine-dataview/schedule";
<DataSchedule
view={view}
onEventClick={(booking, event) => {
toggleEventSelection(view, event.id); // keep selection
openDetail(booking); // plus your action
}}
/>;For full control, a raw onEventClick in scheduleProps / agendaProps / resourcesProps still
overrides everything.
DataSchedule and DataResourceSchedule (not the read-only agenda) accept typed editing callbacks.
Each hands back the original row and the new { start, end } as Dates, and auto-enables the
matching Mantine interaction — you don't also set withEventsDragAndDrop etc.
| Callback | Interaction |
|---|---|
onEventMove(row, { start, end }, ctx) |
drag an event to a new time (resource views: ctx.resourceId) |
onEventResize(row, { start, end }, ctx) |
drag an event's edge to resize |
onRangeSelect({ start, end }, ctx) |
drag-select an empty range (to create) |
onSlotClick({ start, end }, ctx) |
click an empty slot (to create) |
The library doesn't perform the write — pair these with the reconciliation primitives so you persist to your backend and reflect the change instantly:
<DataSchedule
view={view}
onEventMove={(row, {start, end}) => {
api.update(row.id, {start, end}); // your server
view.patchRow({...row, start: start.toISOString(), end: end.toISOString()});
}}
onRangeSelect={({start, end}) => openCreateModal({start, end})}
/>;canDragEvent, canResizeEvent, and external-drag handlers remain available through the
scheduleProps / resourcesProps escape hatch.
A calendar fetches the visible date range, not a page. When a schedule view is active the core
emits a window on the request — map it onto your backend the way you map pagination:
fetcher: async (request) => {
if (request.window) {
// request.window = { start, end, level: "day" | "week" | "month" | "year" }
return api.listEvents({from: request.window.start, to: request.window.end});
}
return api.list(request); // table/cards: paginate as usual
};The backend query must return events that overlap the window, not only those that start inside
it. A start >= from query silently drops an event that begins before the visible week and extends
into it.
In schedule mode the pager is replaced by the calendar's own date navigation, and the toolbar
drops the sort and column controls (a calendar has neither) while keeping search and filters.
DataScheduleNav is exported if you want a standalone prev/today/next + level control.
The visible window round-trips through the query string
(?view=schedule&ws=…&we=…&wl=week), but only when you opt in by listing "window" in
urlSync.include (it's off by default, like selection and pinning):
urlSync: {
adapter,
include: ["pagination", "sorting", "columnFilters", "globalFilter", "view", "window"],
}Recurrence is pass-through only: emit a recurrence: { rrule } object from toEvent and Mantine
expands it into occurrences (there is no recurrence-authoring UI). Interactions on a generated
occurrence resolve back to the series row.
| Export | Purpose |
|---|---|
useDataView |
Headless core, owns all feature state |
useDataViewFetcher |
Convenience wrapper that manages the fetch |
DataViewer (+ .Toolbar / .BulkActions / .Body / .Pagination) |
Orchestrator + compound parts |
DataTable, DataCards |
The two presentations (usable standalone) |
DataToolbar, DataPagination, DataBulkActions |
Standalone affordances |
FilterControl |
Individual filter control (place anywhere) |
ViewSwitcher |
Table/Cards toggle (customizable labels) |
exportCsv |
Standalone CSV export utility |
col |
Fluent column builder factory |
getViewMode |
Detect table vs cards from cell context |
createColumnHelper, composeCardLayout, resolveColumnLabel |
Column helpers |
@ethanhann/mantine-dataview/url |
windowHistoryAdapter + serializer utilities |
@ethanhann/mantine-dataview/schedule |
Opt-in calendar / agenda / resources: scheduleView · agendaView · resourcesView (and DataSchedule / DataAgenda / DataResourceSchedule, DataScheduleNav / DataAgendaNav) |
Returned by useDataViewFetcher (fall back to refetch() on raw useDataView):
| Method / Property | Purpose |
|---|---|
patchRow(record) |
Replace an existing row by identity, then background revalidate |
insertRow(record) |
Prepend a new row, increment rowCount, then revalidate |
removeRow(id) |
Remove a row, decrement rowCount, then revalidate |
isRevalidating |
true while the background revalidation fetch is in flight |
isFetching |
true while any fetch is in flight (incl. keepPreviousData) |
Passed via the slots prop on DataViewer or the presentation components:
| Slot | Receives | Purpose |
|---|---|---|
Empty |
{ view } |
No data state |
ErrorState |
{ retry } |
Error with retry action |
LoadingTable |
— | Table skeleton replacement |
LoadingCards |
— | Card skeleton replacement |
Row |
{ row, cells, rowProps } |
Wrap each table row |
Card |
{ row, data, selected, children } |
Wrap each card |
BulkActions |
{ count, ids, pageRows, clear } |
Bulk action bar content |
The /testing subpath ships an in-memory fetcher and a response builder for app tests and
Storybook fixtures:
import {buildResponse, createMockFetcher} from "@ethanhann/mantine-dataview/testing";
const fetcher = createMockFetcher(FIXTURE_USERS, {
latency: 50, // exercise loading states
summary: (rows) => ({salary: rows.reduce((n, u) => n + u.salary, 0)}),
});
const view = useDataViewFetcher<User>({columns, getRowId, fetcher});createMockFetcher answers requests from the fixed row set: filters (interpreted heuristically
by value shape: string is contains, array is membership, a numeric pair is a range), global
search over string fields, multi-column sort, then pagination, with rowCount reflecting the
filtered total. The heuristics are for tests, not a semantic contract; a real server owns
interpretation. buildResponse(rows, overrides?) derives rowCount for hand-rolled responses.
The build output carries the "use client" directive, so importing any component or hook from
a React Server Component (Next.js App Router) works without a wrapper module. The library
renders on the client; the server's job is to provide the first page of data.
Seed that server-fetched page with initialData so the first paint shows rows instead of the
loading skeleton, and the mount fetch (a duplicate of what the server already did) is skipped:
// app/users/page.tsx (server component)
export default async function UsersPage() {
const first = await api.list({page: 1, size: 10});
return <UsersTable initialData={{rows: first.items, rowCount: first.total}}/>;
}
// users-table.tsx (client component by virtue of the import)
"use client";
export function UsersTable({initialData}: {initialData: DataViewResponse<User>}) {
const view = useDataViewFetcher<User>({columns, getRowId, fetcher, initialData});
return <DataViewer view={view}/>;
}initialData must answer the initial request (the default or initialState-seeded page, sort,
and filters). Every later change fetches normally, and refetch() works immediately.
Deliberate positions on commonly requested capabilities, so scope questions have one answer
(full rationale in data/roadmap-decisions.md):
- Inline cell editing is out of scope. The supported editing pattern is a detail panel or
modal paired with the reconciliation primitives (
patchRow,insertRow,removeRow), which reflects a write instantly and reconciles with server truth in the background. - Virtualization is out of scope. Server pagination keeps pages small by design. Keep page sizes at or below roughly 100 rows; row transitions and keyboard navigation assume fully rendered pages.
- Filter operators are implied by the variant.
textis contains-style,select/multiselect/booleanare equality or membership, and the range variants are between. The server owns interpretation. For an operator-picking UI, use a custom filter component. - Cursor pagination is planned, not available.
paginationis index-based today; a cursor-paged backend cannot be mapped onto it statelessly. The contract will gain an additive cursor slice in a future release. - Schedule times are browser-local. Date windows and event times render in the browser's
timezone. Named-timezone rendering awaits downstream
@mantine/schedulesupport. - RTL is untested. Column pinning uses physical left/right offsets and card arrow-key navigation is physical, so right-to-left locales will have inverted affordances.
npm run dev # Storybook
npm test # Vitest (watch)
npm run test:coverage
npm run typecheck
npm run buildMIT