Skip to content

ethanhann/mantine-dataview

Repository files navigation

@ethanhann/mantine-dataview

npm version CI Coverage Storybook License: MIT

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.

Changelog

See CHANGELOG.md

Features

  • One hook drives both a Mantine Table and a Mantine Card grid; 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: SegmentedControl for booleans, RangeSlider for bounded numbers, DatePickerInput for 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) + a renderCard escape 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.

Install

npm install @ethanhann/mantine-dataview

Peer dependencies

npm install react react-dom @mantine/core @mantine/dates @mantine/hooks @tanstack/react-table

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

Quickstart

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.

Column builder

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();

Available presets

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.

Column widths

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

Custom layout

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

Toolbar sections

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>
    }
/>
  • leftSection renders before the search input (start of the left group).
  • rightSection renders after the view switcher (end of the right group).

Both sections are disabled during loading along with the other toolbar controls.

View switcher

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")

Controlled (bring your own data layer)

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.

External parameters

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".

Refetching on external changes

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.

Manual refresh

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.

Optimistic reconciliation

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.

Column data types and formatting

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

Format overrides (three levels)

  1. Library defaults, the built-in formatters per data type (above).
  2. Table-scoped, formatDefaults on the hook options, keyed by data type.
  3. Column-scoped, format on ColumnMeta, 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.

Sorting

Columns are sortable by default via table header clicks. The request.sorting array is sent to the server so it can apply the sort.

Multi-column sorting

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.

Disabling sorting

Disable sorting on a specific column:

col.accessor("avatar", {header: "Avatar", enableSorting: false});

Custom headers

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"},
});

View mode in cell renderers

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.

CSV export

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"});

JSON export

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"});

Exporting all pages

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>

Column resizing

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.columnSizing keyed by column id. Persist and restore them via initialState: { columnSizing: savedWidths }.
  • With resizing enabled every column carries a concrete width (TanStack's default is 150px); set width on columns that should start wider or narrower.
  • Opt a column out with TanStack's enableResizing: false on 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.

Column reordering

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.

Column pinning

Pin columns to the left or right edge so they stay visible while scrolling horizontally.

Via initial state

const view = useDataViewFetcher<User>({
    columns,
    getRowId,
    fetcher,
    initialState: {
        columnPinning: {left: ["name"], right: ["actions"]},
    },
});

Via the UI

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.

Programmatic

view.table.getColumn("name")?.pin("left");
view.table.getColumn("name")?.pin(false); // unpin

Filters

Built-in filter variants

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

Async filter options

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.

Custom filter component

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}},
});

Inline filter placement

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>

Programmatic filter control

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>

Filter display behavior

  • 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 sm breakpoint): always collapsed into a bottom drawer.
  • A "Reset filters" button appears automatically when any filter is active.

Faceted search

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.

Server response with facets

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

How controls adapt

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.

Facet types

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

Summary aggregates

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.

Card composition

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.

Custom card rendering

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>
        ),
    }}
/>

Bulk actions

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.

Keyboard navigation

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

Row and card activation

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>,
    }}
/>;

Custom state slots

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.

Localization

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 searchPlaceholder or the pager's pageSizeLabel) still win over the dictionary.
  • Cell values already localize through Intl via dataType formatting and the format/formatDefaults pipeline; boolean cell text is customized with a format function.
  • The standalone FilterControl defaults to English. Pass labels={view.labels} when placing it outside the toolbar.

Preference persistence

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.

URL state sync

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.

UrlStateAdapter interface

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 (a popstate listener suffices) unless your app never relies on history navigation.

React Router

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],
    );
}

TanStack Router

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],
    );
}

URL sync options

  • 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.

Responsive behavior

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.

Loading behavior

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

Keep previous data during refetch

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.

Aborting stale fetches

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.

Animated row transitions

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.

Schedule presentation

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.

Install

The schedule presentation wraps @mantine/schedule, which are optional peer dependencies (along with dayjs):

npm install @mantine/schedule dayjs

Import 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";

Add it to a DataViewer

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

Agenda and resource views

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's AgendaView has no navigation of its own, so DataAgenda renders a DataAgendaNav header by default (prev / today / next and a day/week/month range, no year; set withNav={false} to supply your own via leftSection).
  • Resources (resourcesView / DataResourceSchedule) — one row per resource. The rows are derived from the resource-role column's filter options; pass an explicit resources prop (ScheduleResourceData[]) to control labels, colors, or order. Resource views have only day/week/month levels — a year window is clamped to month. 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 with showResourceCounts={false}. Pass a groups prop (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}}/>.

Locking to the schedule view

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.

Week start

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).

Header sections

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>
    }
/>;
  • leftSection renders at the start of the header row, rightSection at the end.
  • They also flow through scheduleView({ leftSection, rightSection }) for the integrated DataViewer.
  • Unlike the toolbar's sections, these are not auto-disabled while loading — read view.status or view.isRevalidating if you want to disable your own controls.

Mapping rows to events

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],
    })}
/>;

Handling event clicks

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.

Editing events

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.

Fetching by date window

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.

URL sync

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"],
}

Scope notes

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.

API overview

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)

Reconciliation primitives

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)

Customization slots

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

Testing your integration

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.

Server-side rendering

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.

Scope positions

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. text is contains-style, select / multiselect / boolean are 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. pagination is 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/schedule support.
  • 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.

Development

npm run dev          # Storybook
npm test             # Vitest (watch)
npm run test:coverage
npm run typecheck
npm run build

License

MIT

About

Resources

License

Stars

1 star

Watchers

0 watching

Forks

Packages

 
 
 

Contributors

Languages