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.
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
TranslationsProviderfrom@uxf/core-react/translationsfor correct labels.
yarn add @uxf/data-gridPeer 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-domImport 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");
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),
});
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);
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 change | URL replaced, no new history entry |
| Back / Forward to the grid, reload | state restored from sessionStorage |
| Fresh navigation via a link | empty grid (the new entry has no param) |
| URL pasted into another tab | empty grid (sessionStorage is per tab) |
| Grid still in its default state | no param at all, no replace on page load |
| Link to the page you are on, Back/Forward within it | the 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 page | applied to every grid, as the old ?filter= persistence did |
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.
Only exports listed below are part of the public surface. Import paths resolve by filesystem (there is no exports map).
@uxf/data-grid| Name | Kind | Description |
|---|---|---|
DataGrid | component | The compound grid. Props: DataGridProps<GridType>. |
DataGridProps<GridType> | type | Grid props = base props + control (state, actions) + data (isLoading, error, data, reload). |
InferDataGridRow<Schema> | type | Derives the row shape from a schema via ColumnTypes. |
BaseGridType, Schema, ChangeTabFilterBehavior, DataGridActionCell, … | types | Re-exported from ./types. |
mergeSchemaWithConfig(schema, config) | fn | Returns a new schema with a frontend config applied (see below). |
encodeFilter(request) / decodeFilter(string) | fn | Base64 (de)serialize a grid request. |
translateLabel(label, t) / translateFilter(filter, t) | fn | Translate a shared: label (see Translated labels); any other label is returned. |
| Import path | Export | Description |
|---|---|---|
@uxf/data-grid/use-data-grid-control | useDataGridControl(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-fetching | useDataGridFetching(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-storage | useUserConfigLocalStorageAdapter(schema), useClearLocalStorageUserConfig(schema) | localStorage persistence of user column config. |
@uxf/data-grid/column-types | ColumnTypes (interface) | Augment to register custom column value types. |
@uxf/data-grid/use-data-grid-filter-draft | useDataGridFilterDraft(control) | Holds filter edits aside until apply() is called. Returns { state, actions, apply } in the useDataGridControl shape. |
@uxf/data-grid/use-data-grid-persist-state | useDataGridPersistState(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.
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>;
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.type | Renders |
|---|---|
checkbox | Checkbox (value is sent only when checked) |
date | Date range (from / to date pickers) |
datetime | Datetime range |
entitySelect | Single-select combobox (async entity) |
entityMultiSelect | Multi-select combobox (async entity) |
interval | Numeric range (min / max inputs) |
multiSelect | Multi-select |
select | Select |
string | Text 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} />;
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.
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}
/>
);
}
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.
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).
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.
localStorage. Render the grid in a client component ("use client").TranslationsProvider required. Labels come from bundled translations via @uxf/core-react/translations.@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.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.