• CMSnpm version

    • Overview
    • ContentBuilder
    • InviteUserForm
    • LoginForm
    • RenewPasswordForm
    • WysiwygInput
  • UInpm version

    • Overview
    • Accordion
    • AlertBubble
    • AnnouncementBar
    • Avatar
    • AvatarFileInput
    • Badge
    • Button
    • ButtonGroup
    • ButtonList
    • Calendar
    • Checkbox
    • CheckboxButton
    • CheckboxInput
    • CheckboxList
    • Chip
    • ColorRadio
    • ColorRadioGroup
    • Combobox
    • DatePicker
    • DatePickerInput
    • DateRangePicker
    • DateRangePickerInput
    • DatetimePicker
    • DatetimePickerInput
    • Dialog
    • Dropdown
    • Dropzone
    • ErrorMessage
    • FileInput
    • FlashMessages
    • FormComponent
    • Icon
    • IconButton
    • ImageGallery
    • InfoBox
    • Input
    • Label
    • Layout
    • Lightbox
    • ListItem
    • Loader
    • Lozenge
    • Menu
    • Message
    • Modal
    • ✅ ModalDialog
    • ✅ ModalHeader
    • MultiCombobox
    • MultiSelect
    • Pagination
    • Paper
    • Popover
    • Radio
    • RadioGroup
    • RasterImage
    • Select
    • Switch
    • ✅ Tabs
    • TextInput
    • TextLink
    • Textarea
    • TimePicker
    • TimePickerInput
    • Toggle
    • Tooltip
    • Typography
  • Formnpm version

    • Overview
    • AvatarFileInput
    • CheckboxButton
    • CheckboxInput
    • CheckboxList
    • ColorRadioGroup
    • Combobox
    • DatePickerInput
    • DateRangePickerInput
    • DatetimePickerInput
    • Dropzone
    • FileInput
    • Form
    • FormRenderer
    • GpsInput
    • MoneyInput
    • MultiCombobox
    • MultiSelect
    • NumberInput
    • PasswordInput
    • RadioGroup
    • Select
    • TextInput
    • Textarea
    • TimePickerInput
    • Toggle
  • DataGridnpm version

    • Overview
    • DataGrid
    • DataGridCustomExample
    • ExportButton
    • FilterList
    • Filters
    • FiltersButton
    • FulltextInput
    • HiddenColumns
    • HiddenColumnsButton
    • Pagination
    • RowCounts
    • RowsPerPageSelect
    • SelectedRowsToolbar
    • TableV2
    • ToolbarControl
    • ToolbarCustoms
    • ToolbarTabs
  • Wysiwygnpm version

    • Overview
  • Resizernpm version

    • Overview
  • Routernpm version

    • Overview
  • Corenpm version

    • Overview
  • Core-Reactnpm version

    • Overview
  • DnDnpm version

    • Overview
  • Page-Buildernpm version

    • Overview
  • Page-Builder-CMSnpm version

    • Overview
  • Page-Builder-Webnpm version

    • Overview
  • Sentrynpm version

    • Overview
  • Stylesnpm version

    • Overview
  • Localizenpm version

    • Overview
  • Analyticsnpm version

    • Overview
  • Datepickernpm version

    • Overview
  • Icons-generatornpm version

    • Overview
  • Smart-addressnpm version

    • Overview
  • E2Enpm version

    • Overview
  • E2E-Playwrightnpm version

    • Overview

@uxf/data-grid

A schema-driven, server-side data grid: one compound DataGrid component with a built-in toolbar, tabs, filters, sorting, column visibility, row selection, pagination and CSV export.

When to use

Reach for @uxf/data-grid when you have a paginated, filterable, server-fetched table driven by a generated schema (columns, filters, tabs). The grid owns fetching, filtering, sorting and pagination state; you provide a schema and a loader.

It is not a headless table primitive and not a spreadsheet — layout, toolbar and footer are opinionated. For a plain, fully custom table render the internal parts yourself (see Custom composition) rather than reaching for a different library.

Note: This package ships translations. Wrap your app in the TranslationsProvider from @uxf/core-react/translations for correct labels.

Installation

yarn add @uxf/data-grid

Peer dependencies (install if not already present):

yarn add @uxf/core @uxf/core-react @uxf/localize @uxf/styles @uxf/ui \
  @dnd-kit/core @dnd-kit/sortable @dnd-kit/utilities dayjs react react-dom

CSS

Import the dependency stylesheets first, then the grid's own stylesheet last. The @uxf/ui component styles are published under the flattened @uxf/ui/css/ directory (the @uxf/ui/<component>/<file>.css source paths do not exist in the published package).

@import url("tailwindcss/components.css");
@import url("@uxf/ui/css/button.css");
@import url("@uxf/ui/css/button-list.css");
@import url("@uxf/ui/css/calendar.css");
@import url("@uxf/ui/css/checkbox.css");
@import url("@uxf/ui/css/chip.css");
@import url("@uxf/ui/css/dialog.css");
@import url("@uxf/ui/css/dropdown.css");
@import url("@uxf/ui/css/icon.css");
@import url("@uxf/ui/css/label.css");
@import url("@uxf/ui/css/form-component.css");
@import url("@uxf/ui/css/input-basic.css");
@import url("@uxf/ui/css/input.css");
@import url("@uxf/ui/css/input-with-popover.css");
@import url("@uxf/ui/css/combobox.css");
@import url("@uxf/ui/css/modal-header.css");
@import url("@uxf/ui/css/multi-select.css");
@import url("@uxf/ui/css/multi-combobox.css");
@import url("@uxf/ui/css/pagination.css");
@import url("@uxf/ui/css/select.css");
@import url("@uxf/ui/css/toggle.css");
@import url("@uxf/ui/css/tabs.css");

/* must be after the component css files */
@import url("@uxf/data-grid/styles.css");

Quick start

useDataGridControl owns grid state and actions; useDataGridFetching runs the loader whenever the request changes. Feed both into DataGrid.

"use client";

import { DataGrid } from "@uxf/data-grid";
import { useDataGridControl } from "@uxf/data-grid/use-data-grid-control";
import { useDataGridFetching } from "@uxf/data-grid/use-data-grid-fetching";
import { schema } from "@generated/data-grid/schema/example";

export function ExampleGrid() {
    const { state, actions } = useDataGridControl({ schema });

    const { isLoading, error, data, reload } = useDataGridFetching({ schema, state });

    return (
        <DataGrid
            actions={actions}
            data={data}
            error={error}
            isLoading={isLoading}
            reload={reload}
            schema={schema}
            state={state}
        />
    );
}

useDataGridFetching uses a default loader that calls the conventional grid endpoint. To fetch yourself, pass a loader:

const { ... } = useDataGridFetching({
    schema,
    state,
    loader: (gridName, request, encodedRequest) => myFetch(gridName, request),
});

Persisting user config

useUserConfigLocalStorageAdapter returns a middleware (persists column visibility, widths, order, etc. to localStorage) and a useUserConfig hook that restores it. Wire the middleware into useDataGridControl and call useUserConfig(actions).

import { useDataGridControl } from "@uxf/data-grid/use-data-grid-control";
import { useUserConfigLocalStorageAdapter } from "@uxf/data-grid/user-config-storage-adapters/local-storage";

const { middleware, useUserConfig } = useUserConfigLocalStorageAdapter(schema);

const { state, actions } = useDataGridControl({
    schema,
    middleware,
    // optional base64-encoded request or a Request object to hydrate initial state
    initialState: encodedRequest,
    // optional default column config
    initialUserConfig: {
        columns: {
            id: { isHidden: true },
        },
    },
});

useUserConfig(actions);

Persisting grid state across navigation

useDataGridPersistState keeps the request (filters, fulltext, sort, paging, tab) across Back/Forward and reload without putting it into the URL. The state goes to the tab's sessionStorage; the URL gets only an opaque ?grid-<schema name>=<id>. Use it instead of writing encodeFilter(...) into the query string: a Sentry replay, server logs and Referer headers record the page URL verbatim, so a URL with filter values leaks whatever the user filtered by.

getPersistedInitialState reads it back for useDataGridControl. In the browser both read the URL from window.location, so they always agree on it; the search params you pass serve only the server render. The package knows no router: the hook gets a replaceSearch(search) that puts a query string into the URL without adding a history entry.

import { getPersistedInitialState, useDataGridPersistState } from "@uxf/data-grid/use-data-grid-persist-state";
import { useDataGridControl } from "@uxf/data-grid/use-data-grid-control";
import { useRouter } from "next/router";
import { useState } from "react";

// pages router
const router = useRouter();
// asPath carries the query on the server of a page with getServerSideProps
const [initialState] = useState(() =>
    getPersistedInitialState(schema.name, new URL(router.asPath, "http://localhost").searchParams),
);
const { state, actions } = useDataGridControl({ schema, initialState });

useDataGridPersistState({
    schema,
    control: { state, actions },
    replaceSearch: (search) => {
        // asPath is without basePath and locale, which replace adds itself; the hash stays
        const [path] = router.asPath.split(/[?#]/);
        return router.replace(`${path}?${search}${window.location.hash}`, undefined, { shallow: true });
    },
});

useReducer uses its initial state once, hence useState(() => …): it saves a storage read on every render.

With the app router, the server's search params come from useSearchParams(), and replaceState updates the URL synchronously:

const searchParams = useSearchParams();
const [initialState] = useState(() => getPersistedInitialState(schema.name, searchParams));

useDataGridPersistState({
    schema,
    control: { state, actions },
    replaceSearch: (search) => window.history.replaceState(null, "", `?${search}${window.location.hash}`),
});

replaceSearch may return anything: whether the param landed is read from the URL afterwards. A replace that does not land (cancelled by a later navigation, rejected, thrown) leaves the grid as it is, and its next change tries again.

How it behaves:

Filter changeURL replaced, no new history entry
Back / Forward to the grid, reloadstate restored from sessionStorage
Fresh navigation via a linkempty grid (the new entry has no param)
URL pasted into another tabempty grid (sessionStorage is per tab)
Grid still in its default stateno param at all, no replace on page load
Link to the page you are on, Back/Forward within itthe grid switches to that entry's state, or to its default state
Old link with ?filter=<base64>applied, then replaced by the grid param
Old link, several grids on the pageapplied to every grid, as the old ?filter= persistence did

Named views

For a view worth linking to ("waiting patients", "unpaid invoices"), give it its own route with a preset rather than a query string: the URL stays readable and carries nothing the user typed. Pass the preset to both hooks. It is the grid's default state for that route, so the URL stays clean until someone changes the grid, and a menu link to the page you are on returns to the preset, not to the schema defaults.

const WAITING: Request = { f: [{ name: "state", value: "waiting" }], page: 0, perPage: 20, search: "" };

const { state, actions } = useDataGridControl({
    schema,
    initialState: getPersistedInitialState(schema.name, serverSearchParams) ?? WAITING,
});

useDataGridPersistState({ schema, control: { state, actions }, initialState: WAITING, replaceSearch });

A view that is just another tab needs no route of its own: schema.tabs covers it.

API

Only exports listed below are part of the public surface. Import paths resolve by filesystem (there is no exports map).

Root export — @uxf/data-grid

NameKindDescription
DataGridcomponentThe compound grid. Props: DataGridProps<GridType>.
DataGridProps<GridType>typeGrid props = base props + control (state, actions) + data (isLoading, error, data, reload).
InferDataGridRow<Schema>typeDerives the row shape from a schema via ColumnTypes.
BaseGridType, Schema, ChangeTabFilterBehavior, DataGridActionCell, …typesRe-exported from ./types.
mergeSchemaWithConfig(schema, config)fnReturns a new schema with a frontend config applied (see below).
encodeFilter(request) / decodeFilter(string)fnBase64 (de)serialize a grid request.
translateLabel(label, t) / translateFilter(filter, t)fnTranslate a shared: label (see Translated labels); any other label is returned.

Deep imports

Import pathExportDescription
@uxf/data-grid/use-data-grid-controluseDataGridControl(config)Owns grid state + actions. Config: { schema, initialState?, initialUserConfig?, middleware? }. actions.replaceRequest(request) swaps the whole request in one dispatch.
@uxf/data-grid/use-data-grid-fetchinguseDataGridFetching(config)Runs the loader on request changes. Config: { schema, state, loader?, isWithTabCounts? }. Returns { isLoading, error, data, reload }.
@uxf/data-grid/user-config-storage-adapters/local-storageuseUserConfigLocalStorageAdapter(schema), useClearLocalStorageUserConfig(schema)localStorage persistence of user column config.
@uxf/data-grid/column-typesColumnTypes (interface)Augment to register custom column value types.
@uxf/data-grid/use-data-grid-filter-draftuseDataGridFilterDraft(control)Holds filter edits aside until apply() is called. Returns { state, actions, apply } in the useDataGridControl shape.
@uxf/data-grid/use-data-grid-persist-stateuseDataGridPersistState(config), getPersistedInitialState(gridName, serverSearchParams?), useIsWaitingForPersistedState(gridName, serverSearchParams?), hasPersistedGridState(gridName, serverSearchParams?)Keeps the request across Back/Forward and reload in sessionStorage, with only an opaque id in the URL. Config: { schema, control, initialState?, replaceSearch }.

Notable optional DataGrid props: actionCell, bodyCells, changeTabFilterBehavior, customActions, filterApplyMode, filterHandlers, fulltextInputPlaceholder, getCsvDownloadUrl, hasStickyHeader, isRowsSelectable, isRowSelectDisabled, isWithTabCounts, keyExtractor, rowAccent, rowClassName, rowHeight, sensitiveColumns, tabsVariant, HiddenColumnsComponent, NoRowsFallback, SelectedRowsToolbarActions, isDebug.

Column types

A column's type (from the schema) maps to a value type via the ColumnTypes interface, which drives InferDataGridRow. Built-in types:

boolean, chip, chips, date, datetime, email, id, int, money, phone, string, url, uuid.

Register a custom column type with module augmentation. Create a column-types.d.ts at the project root (next to tsconfig.json) — it must be an interface:

declare module "@uxf/data-grid/column-types" {
    export interface ColumnTypes {
        "my-custom-type": MyCustomType;
    }
}

Then infer the row type from a schema:

import { InferDataGridRow } from "@uxf/data-grid";
import { schema } from "@generated/data-grid/schema/example";

type Row = InferDataGridRow<typeof schema>;

Filter handlers

Each filter is rendered by looking up filterHandlers[filter.type]. The grid uses defaultFilterHandlers unless you pass a filterHandlers prop. Built-in handler keys:

filter.typeRenders
checkboxCheckbox (value is sent only when checked)
dateDate range (from / to date pickers)
datetimeDatetime range
entitySelectSingle-select combobox (async entity)
entityMultiSelectMulti-select combobox (async entity)
intervalNumeric range (min / max inputs)
multiSelectMulti-select
selectSelect
stringText input

Extend or override by merging into defaultFilterHandlers and passing the result as the filterHandlers prop:

import { defaultFilterHandlers } from "@uxf/data-grid/filter-handler";

<DataGrid filterHandlers={{ ...defaultFilterHandlers, myType: myHandler }} {...rest} />;

Applying filters

By default a filter reaches the grid as soon as its value changes, in the filters drawer and anywhere else the filters are rendered. filterApplyMode="confirm" changes that for the drawer: edits are held back until the user presses the confirm button, and closing the drawer (the close button, ESC or a click outside) discards them, leaving the grid at the last confirmed state.

<DataGrid filterApplyMode="confirm" {...rest} />

Only the filters are deferred — fulltext, sorting, tabs and paging stay immediate, and so do the close buttons on the filter chips below the toolbar. The clear-all button in the drawer header follows the mode: in confirm it empties the drawer only, leaving the grid untouched until the filters are confirmed.

For a filters panel of your own, outside the drawer, useDataGridFilterDraft gives the same behaviour:

const draft = useDataGridFilterDraft({ state, actions });

<DataGridFilters actions={draft.actions} filterHandlers={handlers} schema={schema} state={draft.state} />
<Button onClick={draft.apply}>Apply</Button>;

The draft is seeded when the hook mounts and re-seeded whenever the applied filters change elsewhere, so unconfirmed edits are dropped when a filter chip is closed or a tab is switched.

Frontend config

mergeSchemaWithConfig returns a new schema with per-column and per-filter overrides and perPage applied — useful for tweaking a generated schema without regenerating it.

import { DataGrid } from "@uxf/data-grid";
import { mergeSchemaWithConfig } from "@uxf/data-grid";
import { useDataGridControl } from "@uxf/data-grid/use-data-grid-control";
import { useDataGridFetching } from "@uxf/data-grid/use-data-grid-fetching";
import { schema as baseSchema } from "@generated/data-grid/schema/example";

const schema = mergeSchemaWithConfig(baseSchema, {
    perPage: 100,
    columns: {
        id: { width: 100, isHidden: true },
    },
    filters: {
        id: { placeholder: "Search by ID..." },
    },
});

export function ConfiguredGrid() {
    const { state, actions } = useDataGridControl({ schema });
    const { isLoading, error, data, reload } = useDataGridFetching({ schema, state });

    return (
        <DataGrid
            actions={actions}
            data={data}
            error={error}
            isLoading={isLoading}
            reload={reload}
            schema={schema}
            state={state}
        />
    );
}

Sensitive columns

A column marked sensitive gets data-sentry-mask on its body cells, so Sentry Session Replay masks them. It is a plain attribute: the package has no dependency on Sentry. Mark the column in the PHP grid type, which covers every grid that shows it:

$builder->addColumn(Column::string('personalIdentificationNumber', 'Rodné číslo', …))
    ->replaceConfig(isSensitive: true);

That needs uxf/datagrid 3.84.2 or newer, which projects get through uxf/cms, so composer update "uxf/*" rather than a direct requirement. Use replaceConfig(), not setConfig(new ColumnConfig(...)), which replaces the whole config and drops the column's width or isHidden.

For a grid whose columns are not backend-driven, or on an older uxf/datagrid, name the columns on that grid:

<DataGrid sensitiveColumns={["email", "phone"]} … />

The sources are ORed and never override each other, so a user config restored from localStorage cannot turn masking off. Only body cells are marked: the chips of active filters still show the filtered value.

Translated labels

A column, filter, filter option or tab label that starts with shared: is a translation key and the grid passes it to the translation function from @uxf/core-react/translations. Any other label is printed as it is and never looked up, so literal labels do not produce missing-key warnings.

The backend emits such keys for enum options when uxf_data_grid.gen.schema_with_enum_keys is on (shared:enum.<Enum>.<value>), so the project needs those keys in its shared namespace. Filters are translated before they reach a filter handler, so a custom handler reads props.filter.label translated. A custom composition that renders labels on its own uses translateLabel(label, t).

Custom composition

The DataGrid component is a fixed composition of internal parts. For a fully custom layout, import the parts individually and assemble them yourself, still driving them from useDataGridControl / useDataGridFetching:

@uxf/data-grid/root, @uxf/data-grid/toolbar, @uxf/data-grid/toolbar-tabs, @uxf/data-grid/toolbar-control, @uxf/data-grid/toolbar-customs, @uxf/data-grid/filter-list, @uxf/data-grid/table-v2, @uxf/data-grid/footer, @uxf/data-grid/pagination, @uxf/data-grid/row-counts, @uxf/data-grid/rows-per-page-select, @uxf/data-grid/selected-rows-toolbar, @uxf/data-grid/linear-progress, @uxf/data-grid/body-cell, @uxf/data-grid/filter-handler.

See data-grid.tsx (the default composition) and data-grid-custom-example.stories.tsx in the package for a worked example.

Gotchas

  • Client component only. The hooks use React state, effects and localStorage. Render the grid in a client component ("use client").
  • TranslationsProvider required. Labels come from bundled translations via @uxf/core-react/translations.
  • CSS order matters. Import @uxf/data-grid/styles.css after the @uxf/ui/css/* component styles.
  • ColumnTypes augmentation must be an interface, not a type, or the declaration merge will not apply.
  • initialState is a base64-encoded request string (or a Request object) — use encodeFilter / decodeFilter to (de)serialize. Do not put it into the URL to persist the view, it carries the filter values; use useDataGridPersistState.
  • A stored state is client-only. The server has no sessionStorage, so for a URL with the grid param it cannot know the state. Render a skeleton while useIsWaitingForPersistedState(gridName, serverSearchParams) is true, otherwise hydration will not match. It is true only during the server render and hydration of such a grid; a grid mounted later on the client (Back from a detail page) renders at once. Without the param the grid renders on the server as usual. A statically optimized page has no query on the server, so render its grids on the client only.
  • isDebug is a DataGrid prop, not a useDataGridControl config option.