---
id: "packages/active-grid/props"
title: "Grid props"
description: "Every prop of ActiveGrid and GridDefaults, with its type and default."
url: "/packages/active-grid/props"
locale: "en"
revision: "6e166a860ef335db"
---

# Grid props

> [!NOTE]
> **The package's own reference**
>
> The text below is the package's own API reference, included unchanged. The
> complete list of exports, generated from the source, is on [All
> exports](/packages/active-grid/api).

`<ActiveGrid>` component, `GridProps<TData>` discriminated union, all base
props, imperative handle, settings, pagination, toolbar, header/row menus,
and footer.

## ActiveGrid Component

```tsx
import {ActiveGrid} from '@workspace/active-grid';
```

The main grid component. Accepts `GridProps<TData>` which is a discriminated
union based on the `mode` prop.

### GridProps (Client Mode)

```tsx
<ActiveGrid
  mode="client"
  data={TData[]}
  // ... all common base-props (see section below)
/>
```

### GridProps (Server Mode)

```tsx
<ActiveGrid
  mode="server"
  fetchFn={ServerFetchFunction<TData>}
  // ... all common base-props (see section below)
/>
```

## Common base props

All props shared between client and server mode. These are the
intersection of `GridProps`-client and `GridProps`-server. There is
no standalone type-export for this intersection; use `GridProps<TData>`
and discriminate on the `mode`-field.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `gridId` | `string` | *(required)* | Unique identifier. Used for persistence keys and URL sync. |
| `columns` | `GridColumnDef<TData>[]` | *(required)* | Column definitions array. |
| `height` | `number \| 'auto'` | `400` | Grid height. Number for fixed pixels with scroll; `'auto'` for content-driven height. |
| `enablePersistence` | `boolean` | `false` | Persist grid state (sorting, filters, sizing) to localStorage. Restored state is structurally validated — see [State persistence](#state-persistence). |
| `enableUrlSync` | `boolean` | `false` | Sync grid state to URL query parameters. The grid owns exactly five keys and leaves every other query parameter alone — see [URL state](#url-state). |
| `settings` | `GridSettings<TData>` | `undefined` | Visual settings (row height, striping, footer styling). |
| `title` | `string` | `undefined` | Grid title (displayed in toolbar). |
| `description` | `string` | `undefined` | Grid description. |
| `searchPlaceholder` | `string` | `'Search...'` | Placeholder text for the global search input. |
| `toolbar` | `GridToolbarConfig<TData>` | `undefined` | Toolbar configuration (search, actions). |
| `pagination` | `GridPaginationOptions` | `{enabled: true}` | Pagination configuration. |
| `headerMenu` | `GridHeaderMenuConfig` | `{enabled: true}` | Per-column header menu configuration. |
| `rowMenu` | `GridRowMenuConfig` | `undefined` | Row context menu configuration. |
| `export` | `boolean \| GridExportOptions` | `true` | Export configuration. `true` enables all formats. `onExportComplete(format, scope)` / `onExportError(format, error, scope)` report the outcome of a menu export (ADR-046). `fonts` (`GridExportFonts \| 'inherit'`) sets this grid's PDF export fonts over the `GridDefaults` set (ADR-054). |
| `onRowClick` | `(row: TData) => void` | `undefined` | Callback when a row is clicked. |
| `onRowDoubleClick` | `(row: TData) => void` | `undefined` | Callback when a row is double-clicked. |
| `onCellClick` | `(row: TData, columnId: string, value: unknown) => void` | `undefined` | Fires on cell click with row data, column ID, and value. Both `onCellClick` and `onRowClick` fire on the same click (cell first via event bubbling). |
| `contextMenu` | `ContextMenuBuilder<TData>` | `undefined` | Custom context menu builder: `(row, context?) => items`, called for a source row when its menu opens; `context` is a `ContextMenuContext`. |
| `className` | `string` | `undefined` | Additional CSS class on the grid container. |
| `columnDefaults` | `GridColumnDefaults<TData>` | `undefined` | Default values applied to all columns. |
| `columnTypes` | `GridColumnTypes<TData>` | `undefined` | Extends or overrides the built-in column types per type name; the built-in types apply without it (ADR-045). |
| `initialPageSize` | `number` | `10` | Page size a grid OPENS at when it has no pagination state of its own. Loses to an explicit URL or persisted value, wins over the package default of 10. See [Initial page size](#initial-page-size). |
| `enableColumnResizing` | `boolean` | `true` | Allow columns to be resized by dragging, and adds "Increase width" / "Decrease width" to the column menu as the keyboard path (see `guides/keyboard.md`). |
| `columnResizeMode` | `'onChange' \| 'onEnd'` | `'onChange'` | When to update column size — during drag or after release. |
| `enableColumnPinning` | `boolean` | `true` | Allow columns to be pinned left/right. |
| `enableColumnReordering` | `boolean` | `false` | Allow columns to be reordered via drag-and-drop, and adds "Move left" / "Move right" to the column menu as the keyboard path (see `guides/keyboard.md`). |
| `enableKeyboardNavigation` | `boolean` | `true` | Arrow-key navigation between cells, and Enter to edit an editable cell. On by default since 8.0.0: `role="grid"` without it is an incorrect role declaration. Pass `false` to opt out. |
| `onCellActivate` | `(row: TData, columnId: string) => void` | `undefined` | Fires when Enter/Space activates a focused cell. Not fired for editable cells — there Enter starts the edit instead. |
| `defaultMinSize` | `number` | `120` | Default minimum column width in pixels. Overrides global `columns.minWidth` for this grid. |
| `minGridWidth` | `number` | `undefined` | Minimum total grid width in pixels. Flex columns are distributed over at least this width; container never shrinks below it. When columns exceed this width (e.g. after auto-size), container grows — `minGridWidth` is a floor, not a cap. |
| `enableRowPinning` | `boolean` | `false` | Allow rows to be pinned to top/bottom. |
| `isRowPinnable` | `(row: TData) => boolean` | `undefined` | Predicate to control which rows can be pinned. |
| `maxPinnedRows` | `number` | `10` | Maximum number of pinned rows. |
| `getRowId` | `(row: TData) => string` | Auto-detect `id` field | Custom row-ID resolver. Falls back to `row.id`. Required at the type level when `TData` has a closed key set without an `id` field. Must return a non-empty string; any other answer is refused with an error naming the record's position. Rows the grid generates itself (server-grouping headers, load-more rows) are named by the grid and never passed to it. |
| `enableExpanding` | `boolean` | `false` | Allow rows to be expanded to show detail panels. |
| `renderDetailPanel` | `(row: Row<TData>) => ReactNode` | `undefined` | Render function for the expanded detail panel. |
| `getRowCanExpand` | `(row: Row<TData>) => boolean` | `undefined` | Predicate to control which rows can be expanded. |
| `showRowNumbers` | `boolean` | `false` | Show auto-incrementing row numbers as the first column. |
| `enableGrouping` | `boolean` | `false` | Enable row grouping. Columns with `enableGrouping: true` (default) can be grouped via header menu. Shows `GroupingToolbar` with removable chips when grouping is active, directly above the `role="grid"` element as the top of the grid's card. |
| `initialGrouping` | `GroupingState` (`string[]`) | `[]` | Initial grouping columns. Applied once on mount. |
| `onGroupingChange` | `(grouping: GroupingState) => void` | `undefined` | Callback fired when grouping state changes. |
| `editMode` | `'cell' \| 'row' \| 'batch'` | `'cell'` | Editing mode — single cell, entire row, or buffered batch. Pairs with a commit callback: `'cell'`/`'row'` → `onDataCommit`, `'batch'` → `onBatchCommit`. Without the matching callback editing is disabled entirely (fail-closed) with a dev-warn. |
| `editTrigger` | `'click' \| 'doubleClick'` | `'doubleClick'` | How editing is triggered. `'click'` starts editing on single click (spreadsheet-style). |
| `commitOnBlur` | `boolean` | `false` | When `true`, current edit is committed when focus leaves the cell. Useful for spreadsheet-style editing with `editTrigger="click"`. Moving to another editable cell commits the open edit first and opens the next once that commit is accepted, unless another edit began meanwhile; a refused value keeps the open edit. See `../guides/editing.md` § Auto-cancel. |
| `onDataCommit` | `(payload: EditingCommitPayload<TData>) => Promise<void>` | `undefined` | Callback when edited data is committed. **Required** for `editMode="cell"` / `"row"` — without it those modes offer no editing. |
| `onBatchCommit` | `(payload: BatchCommitPayload<TData>) => Promise<TData[] \| void>` | `undefined` | Callback for "Save all" in batch mode. **Required** for `editMode="batch"` — without it batch mode offers no editing. Unreachable in the other modes. The grid stays editable while it runs; its completion settles only the cells it received, and edits made meanwhile stay dirty for the next save (see the editing guide, "Editing while Save all runs"). Reject to report failure: nothing is settled. |
| `autoSizeOnMount` | `boolean \| Partial<AutoSizeOnMountOptions>` | `false` | Auto-size columns when grid mounts and data is available. |
| `t` | `GridTranslateFunction` | inherited | This grid's translator override; else the nearest `<GridDefaults t>`. Authoritative: a key it does not carry renders as that raw key and is never replaced by English (ADR-034). With neither, the grid throws a configuration error; `englishGridTranslator` is the package English, passed on purpose (ADR-045). |
| `locale` | `GridPresentation` | inherited | This grid's presentation override; else the nearest `<GridDefaults locale>`, replaced whole. A locale code (notation follows the language), or a presentation-context object (`{languageLocale, dateShape?, dateFormat?, timeFormat?, numberFormat?, currencyDisplay?, timeZone?}`) carrying language and notation independently. Relative date presets, the date-filter calendar and `dateTime` filters need `timeZone` and throw a configuration error without it (`guides/filters.md` § Presentation and time zone). Forwarded untouched to the central `@workspace/active-i18n` formatters; the grid never resolves tenant or application settings itself. With neither, the grid throws a configuration error (ADR-034, ADR-045). Object inputs must be referentially stable across renders. |
| `dir` | `'ltr' \| 'rtl'` | inherited | The grid's UI direction: the reading order of its chrome, controls and columns, and the direction every popup it opens carries (ADR-052). Left out, the grid inherits the page's direction (the nearest `dir` attribute, else the computed CSS `direction`) and follows a later change of that attribute. Never inferred from `locale`: set `dir` on a container for locale-driven direction. Any other value throws `GridConfigurationError` (**GRID\_DIRECTION\_INVALID**). Under `rtl` the grid is mirrored (ADR-053): layout, pinning (logical `start`/`end`), scrolling, column virtualization, resize, reorder, keyboard (ArrowLeft/Right visual, Home/End logical), selection, menus, filters and the calendar; data and `aria-colindex` order are unchanged. Not a text direction; see `meta.textDirection`. |
| `meta` | `Partial<GridMeta<TData>>` | `undefined` | Custom metadata passed to the grid instance. Used internally for editing-state, pinning-state, copy-callbacks (`onCopySuccess`, `onCopyError`), aggregations, facets, and more. |
| `virtualization` | `boolean` | `undefined` | Controls row virtualization. `true` forces on, `false` forces off, omit for auto-detect. |
| `virtualizationThreshold` | `number` | `1000` | Row count above which virtualization is enabled (when `virtualization` is not set and `height` is a number). |
| `enableRowSelection` | boolean or `(row: Row<TData>) => boolean` | `false` | Enable selection and its checkbox column; a predicate receives a source/generated row and must narrow before reading `original`. |
| `onSelectionChange` | `(rows: TData[]) => void` | `undefined` | Callback with selected row data on every selection change. |
| `gridRef` | `Ref<GridImperativeHandle>` | `undefined` | Imperative handle. Production consumer code should use `useGridMutation(gridRef)` instead of calling its methods directly — see server-mode.md and ADR-009. |
| `restoreFocusRef` | `React.MutableRefObject<(() => boolean) \| null>` | `undefined` | Ref that receives a restoreFocus callback. Call after closing a modal/dialog to return focus to the last focused cell. Returns `true` if focus was restored, `false` if no cell was tracked. |
| `serverQueryKey` | `string` | `undefined` | Opaque identity of the EXTERNAL query — the part of the question the grid does not own (a page toolbar search box, a folder picker, a URL-carried kind filter). **Server mode only.** When it changes the grid treats the result set as new: cache invalidated, refetch, `pageIndex` back to 0, row selection cleared. Build it from the values themselves so returning to a previous scope is genuinely the same key. Leave it undefined when the grid owns the whole question — the dataset key is then byte-identical to what it was before this prop existed. See server-mode.md. |
| `serverOptions` | `ServerOptions` | `undefined` | Server-mode options: page caching, prefetch, block size. Only applies when `mode="server"`. See server-mode.md. |
| `enableMultiRowSelection` | `boolean` | `true` | Allow multiple row selection. When `false`, only single-select. With multiple row selection the grid declares `aria-multiselectable="true"`; see `../guides/keyboard.md` § ARIA Roles. |
| `onSelectionModelChange` | `(model: SelectionModel) => void` | `undefined` | Structural selection callback. Receives `SelectionModel` discriminated union (`inclusive` / `exclusive`). See `../guides/selection.md`. |
| `selectionActions` | `ReadonlyArray<GridSelectionAction>` | `undefined` | Declarative bulk actions for the current selection. A non-empty array plus `enableRowSelection` makes the grid draw its selection action bar (`[N selected] [Actions v]`) above the body and suppress the footer's selection counter. The shape does not change with the number of actions. See [GridSelectionAction](#gridselectionaction) and `../guides/selection.md`. |
| `onSortingChange` | `(sorting: SortingState) => void` | `undefined` | Callback fired when sort state changes. Receives the new sort spec. |
| `onFilterChange` | `(filters: ColumnFiltersState) => void` | `undefined` | Callback fired when column-filter state changes. |
| `enableColumnVirtualization` | `boolean` | `false` | Enable horizontal (column) virtualization for very wide grids (50+ columns). Only the visible centre columns plus overscan are mounted; **pinned columns are always mounted** and `aria-colcount` / `aria-colindex` keep reporting the full, absolute column set. Explicit opt-in only — there is no threshold and no auto-detection, unlike row virtualization. **Requires a numeric `height`**; with `height="auto"` the grid dev-warns and does not clamp. Autosize still measures real cell content: the grid suspends the window for the duration of a measurement pass. Not supported under RTL. See ADR-030. |
| `loadingDisplay` | `LoadingIndicator \| LoadingDisplayOptions` | see below | What the grid shows while a fetch is in flight. Nothing on first load; the 2-pixel bar on a refetch. See [Loading display](#loading-display). |
| `externalFilter` | `(row: TData) => boolean` | `undefined` | Consumer-supplied row-predicate applied alongside column filters. **Client mode only** — in server mode the predicate is never called and the grid dev-warns once; see [Filter axis: client mode only](#filter-axis-client-mode-only). |
| `postSortRows` | `(rows: TData[]) => TData[]` | `undefined` | Re-ranks the sorted top-level rows. Receives their records in sorted order and returns them in the wanted order; the result is a ranking, not a row list: an omitted record keeps rank 0, a repeated record takes its last position, an unknown record adds nothing, and ties keep the sorted order. Under grouping each group is represented by its first member record in filtered order. Only the top-level rows move: each keeps its sub-rows in their order, and the flat row list follows the new order. Must be a pure function of its input: the grid calls it again when the sorted rows, the grouping or the function itself change, never merely because rows are read, so a changed ranking needs a new function. Not applied when sorting is manual. |
| `enableGroupFooter` | `boolean` | `false` | Render a subtotal/footer-row at the end of each group (client-side grouping only). |
| `enableServerGrouping` | `boolean` | `false` | Enable server-side grouping mode. Server returns `isGrouped` and `groups` metadata; the outer grouping hook constructs synthetic display rows. See `../guides/server-grouping.md`. |
| `serverGroupBy` | `GroupByColumn[]` | `undefined` | Ordered `{field, sort?}` declarations passed to `fetchFn` as `params.groupBy`. |
| `enableResponsiveHiding` | `boolean` | `false` | Hide columns automatically based on viewport breakpoints. Use with `responsiveBreakpoints` to declare hide-thresholds per column. |
| `responsiveBreakpoints` | `Record<string, number>` | `undefined` | Column-id → minimum viewport width (px) below which the column is hidden. |
| `enableCellSelection` | `boolean` | `false` | Cell-range selection: drag, Shift+click and Shift+arrow extend from the active cell (the anchor), Ctrl/Cmd+click adds, Ctrl/Cmd+Shift+click replaces the last range; a click selects the active cell and stores no range. Ctrl+C and the context menu copy it. Also makes the body context menu range-aware. No paste or fill. Turning it off ends every range (reported as `[]`); turning it on again brings none back. See `../guides/keyboard.md` § Cell selection. |
| `onCellSelectionChange` | `(ranges: CellRange[]) => void` | `undefined` | Called with every explicit range after each finished change (once at the end of a drag), and with `[]` when the selection ends. A click changes the active cell, not the ranges, and reports nothing; a single selected cell is `[]`. |
| `enableFilterBuilder` | `boolean` | `false` | Show the AND/OR filter-builder panel UI for composite filter expressions. **Client mode only** — in server mode the button and panel are not rendered and the grid dev-warns once. See `../guides/filter-builder.md`. |
| `initialFilterExpression` | `FilterExpression` | `undefined` | Initial filter-tree (AND/OR composition) for the filter-builder. Client mode only. Consumer-owned: the grid does not persist the expression itself. |
| `onFilterExpressionChange` | `(expr: FilterExpression \| null) => void` | `undefined` | Callback when the filter-builder tree changes. Never fires in server mode, where no expression can be composed. |
| `enableUndoRedo` | `boolean` | `true` with `onDataCommit`, else `false` | Enable Ctrl+Z / Ctrl+Y undo/redo for cell edits (not in `editMode="batch"`). Edit-history lives in the mounted grid and is dropped on unmount. See `../guides/editing.md` § Undo/redo. |
| `maxUndoDepth` | `number` | `50` | Maximum number of undoable edits retained in history. Older edits are discarded. |

## Loading display

### Why the default is nothing

A grid has three states, and they are not interchangeable: it has **not
loaded yet**, it has **loaded and found nothing**, or it has **loaded and has
rows**. Two of those look identical if you only count rows, which is how a
grid ends up saying "No results" while it is still fetching.

Two things follow, and they are of different kinds.

**Whether an indicator is shown is your decision, not the library's.** Only
you know whether your data arrives in 50 ms or in 3 seconds. An indicator that
appears and disappears within 80 ms is more disturbing than an empty area, and
a library that guesses will be wrong for half its consumers. So the default for
a first load is `'none'`.

**Whether the grid states something untrue is not your decision.** Regardless
of `loadingDisplay`, the pagination footer stays silent — no row count, no page
indicator — until a fetch has answered. That is correctness and it is not
configurable. With `'none'` as the default it matters more, not less: it is the
only thing between an empty area and a false statement.

A refetch is the one place the library still leans in. Rows are on screen and
they have just gone stale; a reader given no cue is looking at old data without
knowing it. The default there is the 2-pixel bar. Set `refetch: 'none'` if you
disagree.

### Type

```ts
type LoadingIndicator = 'none' | 'progress' | 'skeleton' | 'spinner' | 'text';

// An indicator name, or your own node rendered inside the grid box.
type LoadingDisplayValue = LoadingIndicator | React.ReactNode;

interface LoadingDisplayOptions {
  firstLoad?: LoadingDisplayValue; // default 'none'
  refetch?: LoadingDisplayValue; // default 'progress'
  delayMs?: number; // default 0
}

loadingDisplay?: LoadingIndicator | LoadingDisplayOptions;
```

All three are exported, so a wrapper can name them:

```ts
import type {
  LoadingDisplayOptions,
  LoadingDisplayValue,
  LoadingIndicator,
} from '@workspace/active-grid';

// A per-screen choice passed down to a shared grid wrapper.
function AppGrid({busyState}: {busyState: LoadingDisplayValue}) { … }
```

### Indicators

| Value | What you see | When it fits |
| --- | --- | --- |
| `'none'` | Nothing. | Data arrives fast, or you render your own indicator outside the grid. |
| `'progress'` | 2-pixel indeterminate bar along the top edge. Covers nothing; rows stay visible and interactive. | Slow queries where the rows must stay readable. The refetch default. |
| `'skeleton'` | Placeholder rows matching the column widths, over an opaque background. Row count follows `pageSize`. | First loads over ~1 s, where the shape of the table is already meaningful. |
| `'spinner'` | Centered spinner over an opaque background. | Small grids whose column shape says nothing. |
| `'text'` | One non-blocking line at the top edge ("Loading…" / "Refreshing data…"). | Dense screens where a skeleton is too loud but silence is confusing. |
| `ReactNode` | Whatever you pass. | You have your own loader and want it here. |

Two details worth knowing. `'skeleton'` renders **nothing** when the column
widths are not resolved yet — it does not silently become a spinner. And the
bar and the skeleton stop animating under `prefers-reduced-motion`; they stay
visible, they just hold still.

### Why `'skeleton'` stays

`'skeleton'` is an explicit library capability. Host applications decide which
loading presentations their own design standards permit; supporting a value
does not require a host to use it. The repository's
`PAGE_STANDARDS.md` section 7
restricts product loading UI while allowing this library capability.

Nothing about the default behaviour depends on this. `firstLoad` defaults to
`'none'` and `refetch` to `'progress'`, so a consumer that says nothing never
sees a skeleton — it has to be asked for by name.

### Every way to write it

```tsx
// Default: nothing on first load, the bar on a refetch.
<ActiveGrid mode="server" fetchFn={fetchFn} columns={columns} gridId="a" />

// Short form — symmetric. This is skeleton in BOTH states.
<ActiveGrid loadingDisplay="skeleton" ... />

// Per state.
<ActiveGrid loadingDisplay={{firstLoad: 'skeleton', refetch: 'progress'}} ... />

// Only override one; the other keeps its default.
<ActiveGrid loadingDisplay={{firstLoad: 'text'}} ... />

// Hold anything back until the fetch has run 300 ms. A faster fetch
// shows nothing at all — this is what removes the flash.
<ActiveGrid loadingDisplay={{firstLoad: 'skeleton', delayMs: 300}} ... />

// Your own node — any ReactNode. The grid box is the positioning context.
<ActiveGrid loadingDisplay={{firstLoad: <div>Loading orders…</div>, refetch: 'none'}} ... />

// Silence everywhere.
<ActiveGrid loadingDisplay="none" ... />
```

`delayMs` is recommended alongside every visible indicator; 200-300 ms is a
common choice. It is not defaulted to a non-zero value on purpose — that would
be the library deciding again.

## Filter axis: client mode only

`externalFilter` and `enableFilterBuilder` are declared on the shared base-props,
so the type-checker accepts them in both modes — but both are applied in exactly
one place, and that place is client-only. `use-grid-setup.ts` filters the client
dataset under `mode === 'client'`; the filter-builder's AND/OR expression is
evaluated into that same predicate. Nothing is serialised onto the wire:
`ServerFetchParams` carries `filters`, `sorting` and `globalFilter`, and has no
expression field.

The two get different treatment in server mode, because they fail differently:

| Prop | Server-mode behaviour | Why |
| --- | --- | --- |
| `externalFilter` | Ignored, dev-warns once per grid | There is no UI to withhold — the consumer wrote a predicate in their own code. Applying it to the loaded page instead would filter one page of a server dataset and make the row counts lie. |
| `enableFilterBuilder` | Toolbar button and panel are not rendered; dev-warns once per grid | A *user*, not a developer, composes the expression. A console warning never reaches them, so the affordance itself is withheld rather than left over a discarded result. |

Both warnings are development-only (silent in production) and fire only when the
prop is actually set — an ordinary server-mode grid is never warned about.

**Consequence worth stating explicitly.** When `externalFilter` is used to *hide*
rows (a view-tab predicate, a scope restriction), a server-mode grid shows the
user more than the consumer intended. That is a visibility gap, not a data leak:
permission and tenant scoping are enforced server-side by the resource layer and
never depend on this predicate. Still, move the condition into the server query
(`fetchFn` params, or a resource filter) rather than relying on the predicate.

Roadmap item `F-12` covers wiring the expression tree into the server query. This
is "not yet", not "never".

## Initial page size

`initialPageSize` is the page size a grid **opens at** when it has no pagination
state of its own. It is a starting value, not an override: it never replaces
what a user chose or shared.

The `initial…` prefix is deliberate. A grid starting value is one link in a
precedence chain with URL and stored state (`GridStatePriority`), so it is not
the plain React initial value of uncontrolled state that a `default…` prop names
elsewhere in the package family (ADR-045 § 8). Callback names (`on…`) are the
same in both packages.

### Precedence

From strongest to weakest:

| # | Source | Wins when |
| --- | --- | --- |
| 1 | An explicit, valid pagination state in the URL (`enableUrlSync`) | The query string carries `page` and/or `pageSize` |
| 2 | An explicit, valid persisted state (`enablePersistence`) | `localStorage` holds a structurally valid slice |
| 3 | `initialPageSize` on this grid | Neither state source says anything |
| 4 | `pagination.pageSize` from the nearest `GridDefaults` | The prop is absent or invalid |
| 5 | The package default of `10` | Nothing above says anything usable |

Sources 1 and 2 are ordered against each other by `statePriority` (default
`'url-first'`) — see [Precedence against localStorage](#precedence-against-localstorage).
That arbitration is unchanged; the prop and the boundary sit strictly below
both.

Steps 3, 4 and 5 are the same shape every other `GridConfig` setting has: a
per-grid prop wins over the nearest `GridDefaults`, and that boundary wins over
the package default. See `columns.md` § GridDefaults.

Consequences worth stating:

- **`pageIndex` always starts at `0`.** The prop names a size, never a page.
- **The first render already carries the size.** The value seeds the store
  rather than arriving from an effect, so there is no visible jump from 10 to
  the configured size. Server and client agree on it — it comes from a prop, not
  from `localStorage` or the URL — so it is also hydration-safe.
- **A later render with a different value does not reset a running grid.** The
  starting state is decided once, at mount.
- **A stale persisted or URL value still wins.** A user who once chose 100 keeps
  100, even when the code later declares `initialPageSize={20}`. Clearing the
  stored state (`clearAllGridStates()`) brings the grid back to
  `initialPageSize`, not to 10.
- **Two grids are independent.** Each `gridId` has its own store and its own
  starting size. Two grids under the same provider share the baseline unless one
  of them sets `initialPageSize`.
- **A provider re-render does not reset a running grid.** The starting state is
  decided once, at mount, from both sources. To see a new configured value,
  remount the grid.

### Invalid values

A value that is present but not a whole number greater than zero (`0`, a
negative, a fraction, `NaN`) is **refused**, not repaired. It is not clamped to
1 and not rounded — either would turn a typo into a working grid that silently
disagrees with its source. A `console.warn` in development names the grid and
the source that was rejected.

A refused value counts as **absent**, so the chain simply continues: an invalid
`initialPageSize` falls through to `GridConfig.pagination.pageSize`, and an
invalid provider value falls through to the package default. The other reading —
a typo in one grid discards the deliberately configured platform value — would
punish the installation for a mistake somewhere else.

The value is deliberately **not** checked against `pageSizeOptions`. That list
validates the URL, which is user input and an abuse surface (`pageSize=10000`);
`initialPageSize` comes from the developer composing the grid, and the paginator
already appends a size outside the list to its selector.

> **Fixed in 10.1.3.** Before that release the prop only reached the state
> through the pagination branch of state hydration, which runs only when the URL
> or `localStorage` already carries a pagination slice. On a fresh grid — exactly
> what "initial" names — it did nothing.
>
> **Extended in 10.2.0.** `GridConfig.pagination.pageSize` was declared,
> defaulted and documented — with a provider example that invited consumers to
> set it — and read by nothing. It is now step 4 of the chain above. A consumer
> who already set it and saw no effect will now get the value they asked for.

## State persistence

With `enablePersistence`, grid state is written to `localStorage` under
`active_grid_state_<gridId>`. Two further namespaces exist for auxiliary state:
`active_grid_state_locks_<gridId>` (locked columns) and
`active_grid_state_pins_<gridId>` (pinned rows).

### What is validated on load

A stored entry is only trusted after two checks:

1. **Version.** A `version` that does not match the configured storage version
   is discarded and the key is removed.
2. **Shape, per slice.** Every slice is checked against the structure it is
   supposed to have — `sorting` an array of `{id: string, desc: boolean}`,
   `columnSizing` a record of finite numbers, `pagination` an object with a
   non-negative `pageIndex` and a positive `pageSize`, and so on. The same
   applies to the two auxiliary namespaces.

A version check alone is not enough: state can be corrupt *within* a valid
version, for instance after a hand-edit of `localStorage` or a restore from
another source.

### What happens to an invalid slice

The slice is **ignored** and falls back to its default. It is never repaired
or converted — there is no migration mechanism.

Validation is **per slice, not all-or-nothing**: the remaining slices are
restored normally. A single corrupt `columnOrder` therefore does not cost you
your column widths or your saved filters. This is a robustness boundary for UI
preferences, not a security boundary, so discarding state the user could have
kept is the worse outcome.

A completely unreadable entry (not JSON, or JSON that is not an object) yields
no state at all and never throws.

### The warning

Each ignored slice logs a `console.warn` naming the slice, the grid ID and the
kind of value that was rejected:

```text
[active-grid] ignored persisted "pagination" for grid "audit-log" — the stored
value does not match the expected shape (received an array of 2). The slice
falls back to its default; the other slices were restored.
```

This warning appears in **development only** (`process.env.NODE_ENV !==
'production'`). The slice is dropped in production too — only the message is
suppressed. State that disappears without a signal is the hardest kind to
explain, which is why the drop is announced at all.

### What is written

A save writes exactly the slices a load restores: `sorting`, `columnFilters`,
`columnVisibility`, `columnOrder`, `columnSizing`, `columnPinning`,
`pagination`, `globalFilter`, `rowPinning` and `expanded`, plus the storage
`version` and a `timestamp`. Runtime state is never written: row selection,
grouping, the live resize bookkeeping and cell selection belong to one visit.
An older entry that still carries such fields loads without complaint; they
are not restored, and the next save leaves them out.

A column width the reader set by dragging is a lock in
`active_grid_state_locks_<gridId>`. The locks are read before the grid's first
layout pass, and no layout pass runs before they are known, so a restored
width survives the reload and every later resize of the viewport.

## URL state

With `enableUrlSync`, the grid mirrors part of its state into the query string
so a view can be shared, bookmarked and reloaded.

### What the grid owns

Exactly five keys, and this list is exhaustive:

| Key | Grid state | Shape |
| --- | --- | --- |
| `page` | `pagination.pageIndex` | 1-indexed (`page=3` is `pageIndex: 2`) |
| `pageSize` | `pagination.pageSize` | omitted when it equals the package default of 10 |
| `sort` | `sorting` | `name:asc,age:desc` |
| `filters` | `columnFilters` | `salary:greaterThan:50000;type:in:city,village` |
| `search` | `globalFilter` | URL-encoded term |

`page` and `pageSize` are read strictly: only decimal digits state a number, and
both must be a whole number greater than zero. `page=2junk`, `page=1.5`,
`page=-1`, `page=0` or an empty value states no page, so the grid keeps its
starting page instead of a truncated or clamped one; `pageSize` follows the same
rule.

No other grid state reaches the URL. Column sizing, ordering, pinning,
visibility, row pinning and client expansion are **localStorage-only**, under
`enablePersistence`. Server-group expansion is separate, non-persisted hook
state (see Server grouping) — they are per-device preferences, not part of a view you
share with a colleague.

### What the grid does NOT touch

**A grid manages only its own URL state.** Every query parameter outside the
five keys above belongs to the application, and the grid carries it through
untouched: same value, same multiplicity (two `?tag=` pairs stay two pairs),
same relative order. It is never rewritten, re-encoded or dropped.

This matters for composition. A route can carry a tab id under `?section=`, a
server-evaluated view under `?view=`, or any other app-owned deep-link
dimension, and put a URL-synced grid on the same page:

```text
/records?view=recent&page=2&sort=createdAt:desc
```

Sorting, paging, filtering, searching and clearing the grid state all leave
`view=recent` exactly where it is.

> **Fixed in the release after 9.0.0.** The write-back used to replace the whole
> query string, so an app-owned parameter disappeared on the first grid
> interaction — a deep link that worked once and then quietly did not. Consumers
> whose routes carry no parameters besides grid state see no behavioural change
> at all: for them the merge produces the same string the replacement did.

**Not promised: byte-exact reproduction of the whole URL.** The grid
re-serializes its own keys from state, so their order and encoding come from the
serializer and they are appended after the application's parameters.

### History semantics

The write-back uses `history.replaceState`, never `pushState`: a sort is not a
navigation, and back/forward keep meaning what they meant before the grid state
changed. This is unchanged behaviour.

### Two grids on one route

The five keys are **global to the route**, not namespaced per `gridId`. Two
URL-synced grids on the same route therefore compete for them and the last
writer wins. Give at most one grid per route `enableUrlSync`; a second grid on
the same route uses `enablePersistence` only. (The grid does not warn about
this — it cannot see its siblings.)

### Precedence against localStorage

When both `enableUrlSync` and `enablePersistence` are on, the four contested
slices (sorting, filters, global filter, pagination) resolve per
`statePriority`, default `'url-first'`. A shared link therefore wins over the
recipient's own saved state, which is what makes it a link worth sharing.

## GridImperativeHandle

```tsx
import {type GridImperativeHandle} from '@workspace/active-grid';
```

| Method | Description |
| --- | --- |
| `refetch()` | Invalidate server-side cache and re-fetch current page. No-op in client mode (emits dev-warning). |
| `resetSelection()` | Clear all row selections. |
| `addRow(row)` | Insert a row into the local cache. Updates `totalCount`. No-op in client mode (emits dev-warning). |
| `removeRow(rowId)` | Remove a row from the local cache. Decrements `totalCount`. No-op in client mode (emits dev-warning). |

> **Production consumer-code must use `useGridMutation`** instead of calling
> these methods directly. See `server-mode.md` for the
> hook and the three exception classes (library tests, demo pages, pure UI
> commands outside a mutation flow).

## SelectionModel

```typescript
import {type SelectionModel} from '@workspace/active-grid';
```

Discriminated union for row-selection state:

```typescript
type SelectionModel =
  | {mode: 'inclusive'; rowIds: string[]}
  | {
      mode: 'exclusive';
      excludedRowIds: string[];
      totalCount: number;
      query: SelectionQuery;
    };
```

Inclusive mode (default) tracks selected rows by ID. Exclusive mode (used
for server-side "select all 5000 rows") tracks excluded rows; everything
else is considered selected.

**`totalCount` is for display.** It is what the last response reported. A
mutation must never compute its own effect from it: rows can appear or
disappear between the selection and the call, and the number arrives from the
client anyway.

## SelectionQuery

```typescript
import {
  type SelectionQuery,
  type SelectionQueryFilter,
} from '@workspace/active-grid';
```

The QUESTION an exclusive selection answers. "Everything except these ids" is
meaningless without it: a consumer that cannot see the query either processes
the rows it happens to have loaded (silently wrong) or the whole table
(dangerously wrong).

```typescript
interface SelectionQueryFilter {
  readonly id: string;
  readonly value: unknown;
}

interface SelectionQuery {
  /** Column filters the grid itself owned at selection time. */
  readonly filters: ReadonlyArray<SelectionQueryFilter>;
  /** The grid's own search term; `''` when the grid has no search. */
  readonly search: string;
  /** The consumer's `serverQueryKey`, verbatim; `undefined` when unused. */
  readonly externalKey: string | undefined;
}
```

Split by OWNER: `filters` and `search` are the grid's, `externalKey` is the
consumer's. The grid never parses any of it — it is carried, not interpreted,
so a consumer can verify the selection still belongs to the scope on screen.
A mutation must still re-derive and re-authorize all of it server-side; see
`../guides/selection.md`.

## GridSelectionAction

```typescript
import {
  type GridSelectionAction,
  type GridSelectionActionTone,
} from '@workspace/active-grid';
```

One action offered on the current selection. The grid owns the bar, the count,
the fixed Actions menu, the keyboard behaviour and the two presentations; the
consumer owns which actions exist, what they are called and what they do.

| Property | Type | Default | Description |
| --- | --- | --- | --- |
| `id` | `string` | — | Stable identity. Never a translated label. |
| `label` | `string` | — | Visible text, already translated by the consumer. |
| `icon` | `React.ComponentType<{className?: string}>` | `undefined` | Optional leading icon component. |
| `tone` | `GridSelectionActionTone` (`'default' \| 'danger'`) | `'default'` | `danger` renders the destructive menu-item presentation. |
| `separatorBefore` | `boolean` | `false` | Draw a separator above this item. |
| `supportsDatasetWideSelection` | `boolean` | `true` | Whether the action can execute an `exclusive` selection. See below. |
| `onAction` | `(selection: SelectionModel) => void \| Promise<void>` | — | Receives the current model unchanged. An async handler holds the whole menu busy until it settles. |

**Permission is absence.** An action the caller may not perform is left out of
the array; the grid never renders a disabled item to stand for a missing right.

**There is no `ReactNode` slot.** A free slot would let every consumer design
its own bulk bar again, which is the divergence this contract replaces. One
action renders in the same menu as five.

**`supportsDatasetWideSelection` closes the affordance gap.** A grid that
offers "select all 5000 rows" next to an action that can only process explicit
ids invites the reader into a state where the action either does nothing or
silently does less than it says. The declaration works in both directions:

- an action that cannot execute the exclusive shape is left OUT of the menu
  while such a selection is active;
- when NO declared action can execute it, the grid does not offer dataset-wide
  select-all in the first place.

Set it to `false` when the action needs explicit ids — because its server side
takes an id list, or because the work is unbounded and has no background
carrier. Leave it `true` when the server can re-derive the query, re-authorize
it and apply the exclusions itself.

## GridSettings

```typescript
import {type GridSettings} from '@workspace/active-grid';
```

Visual settings via the `settings` prop:

| Property | Type | Default | Description |
| --- | --- | --- | --- |
| `rowHeight` | `number` | `42` | Row height in pixels. |
| `headerHeight` | `number` | `48` | Header height. |
| `groupHeaderHeight` | `number` | `32` | Group header height. |
| `filterBarHeight` | `number` | `36` | Filter bar height. |
| `paginationHeight` | `number` | `undefined` | Pagination bar height. |
| `stripedRows` | `boolean` | `false` | Enable alternating row colors. |
| `stripeClassName` | `string` | `undefined` | Custom class for striped rows. |
| `showColumnBorders` | `boolean \| 'none' \| 'partial' \| 'full'` | `'none'` | Column border mode. `'partial'` = 50% height, `'full'` = 100%. `true`/`false` are backwards-compatible aliases. |
| `autoSizeMaxBodyWidth` | `number` | `undefined` | Max width (px) that body-cell content may contribute to auto-size. Header width always wins. Per-column override via `meta.autoSizeMaxBodyWidth`. |
| `disableHoverEffect` | `boolean` | `undefined` | Disable row hover background. |
| `noDataMessage` | `string` | `'No results found.'` | Message when grid has no data. |
| `noDataOverlay` | `ReactNode` | `undefined` | Custom empty-state component. |
| `rowClassRules` | `Record<string, (data: TData) => boolean>` | `undefined` | Conditional CSS classes on rows. |
| `showFilterRow` | `boolean` | `undefined` | Filter row visibility: `true` = always show, `false` = always hide, `undefined` = auto (show when ≥1 column has `meta.filterType`). |
| `footer` | `GridFooterConfig` | `undefined` | Footer styling. |
| `header` | `GridHeaderConfig` | `undefined` | Header styling and behavior. |
| `pagination` | `GridPaginationStyleConfig` | `undefined` | Pagination styling (`containerClassName`). |

### GridHeaderConfig

| Property | Type | Default | Description |
| --- | --- | --- | --- |
| `filterRowClassName` | `string` | `undefined` | CSS class for the filter row. |
| `menuPosition` | `'left' \| 'right'` | `'right'` | Position of the menu button within header cells. |
| `showMenuOnHover` | `boolean` | `true` | Show menu trigger only on hover (`true`) or always visible (`false`). |
| `showFilterIcon` | `boolean` | `true` | Show filter-active icon in header cells when column has an active filter. |
| `showColumnSeparators` | `boolean` | `true` | Show column separator lines between header cells (controlled by CSS vars). |

### GridFooterConfig

| Property | Type | Default | Description |
| --- | --- | --- | --- |
| `className` | `string` | `undefined` | CSS class for the footer container row (background, borders, etc). |
| `cellClassName` | `string` | `undefined` | CSS class for individual footer cells. |

## GridPaginationOptions

| Property | Type | Default | Description |
| --- | --- | --- | --- |
| `enabled` | `boolean` | `true` | Enable pagination. With `false`, a client grid presents every filtered and sorted row as one list: no page is cut and a page size is ignored; row virtualization still limits what is mounted. A server grid keeps its server paging. |
| `mode` | `'paginated' \| 'infinite'` | `'paginated'` | Pagination mode. `'infinite'` enables scroll-triggered batch-loading. **Requires `mode="server"`** — on a client-mode grid the setting is ignored (the grid falls back to standard pagination) and a dev-warn is emitted once per `gridId`. |
| `position` | `'top' \| 'bottom' \| 'both'` | `'bottom'` | Where to render pagination. |
| `pageSizeOptions` | `number[]` | `[10, 20, 50, 100]` | Available page sizes. |
| `showRowCount` | `boolean` | `true` | Show "1 – 50 of 5,000" row-count summary. |
| `showPageSizeSelector` | `boolean` | `true` | Show page-size selector dropdown. |
| `showPageIndicator` | `boolean` | `true` | Show "Page 1 of 100" page indicator. |
| `showFirstLastButtons` | `boolean` | `true` | Show first/last page buttons (« »). |
| `showPrevNextButtons` | `boolean` | `true` | Show prev/next page buttons (‹ ›). |
| `rowCountPosition` | `'left' \| 'right'` | `'left'` | Position of row-count summary. |
| `className` | `string` | `undefined` | Additional CSS class on the pagination container. |

## GridToolbarConfig

```typescript
import {type GridToolbarConfig} from '@workspace/active-grid';
```

Configuration for the toolbar above the grid. **Opt-in**: only renders when
at least one feature is enabled (`search.enabled`, `showColumnChooser`, or
`actions`).

| Property | Type | Default | Description |
| --- | --- | --- | --- |
| `hidden` | `boolean` | `undefined` | Force-hide the toolbar. |
| `className` | `string` | `undefined` | CSS class on the toolbar container. |
| `search` | `{ enabled, placeholder, className, debounceMs }` | `undefined` | Search input configuration (inline shape). See below. |
| `showColumnChooser` | `boolean` | `false` | Show the Column Chooser button. Opens a dialog for toggling column visibility. Appears only when at least one column has `enableHiding: true`. |
| `actions` | `ReactNode \| ((grid: Grid<TData>) => ReactNode)` | `undefined` | Custom toolbar actions (buttons, dropdowns). |

### Search input (inline shape)

| Property | Type | Default | Description |
| --- | --- | --- | --- |
| `enabled` | `boolean` | `false` | Enable the search bar in the toolbar. |
| `placeholder` | `string` | `'Search...'` | Search input placeholder text. |
| `className` | `string` | `undefined` | CSS class on the search input. |
| `debounceMs` | `number` | `300` | Debounce delay for the global filter in ms. |

## GridHeaderMenuConfig

```typescript
import {type GridHeaderMenuConfig} from '@workspace/active-grid';
```

Per-column header-menu (3-dot button) configuration. Set globally via the
`headerMenu` prop or per-column via `meta.headerMenu`.

| Property | Type | Default | Description |
| --- | --- | --- | --- |
| `enabled` | `boolean` | `true` | Whether the menu is enabled. |
| `showLabels` | `boolean` | `false` | Show text labels above sections (e.g. "SORT"). |
| `className` | `string` | `undefined` | CSS class for the dropdown container. |
| `itemClassName` | `string` | `undefined` | CSS class for individual menu items. |
| `separatorClassName` | `string` | `undefined` | CSS class for separators. |
| `features` | `{ sort, pin, resize, visibility, grouping, reset }` | All enabled | Which features to show. See below. |

### Header-menu features (inline shape)

| Property | Type | Default | Description |
| --- | --- | --- | --- |
| `sort` | `boolean` | `true` | Ascending/Descending options. |
| `pin` | `boolean` | `true` | Pin left/right options. |
| `resize` | `boolean` | `true` | Auto-size options. |
| `visibility` | `boolean` | `true` | Hide column option. |
| `grouping` | `boolean` | `true` | Group-by column (only when `enableGrouping` is on). |
| `reset` | `boolean` | `true` | Reset table layout. |

## GridRowMenuConfig

```typescript
import {type GridRowMenuConfig} from '@workspace/active-grid';
```

Row context-menu (right-click) configuration via the `rowMenu` prop.

| Property | Type | Default | Description |
| --- | --- | --- | --- |
| `enabled` | `boolean` | `true` | Whether the context menu is enabled. `false` offers no grid menu on any row; the browser keeps its own. |
| `className` | `string` | `undefined` | CSS class for the menu container. |
| `itemClassName` | `string` | `undefined` | CSS class for individual items. |
| `separatorClassName` | `string` | `undefined` | CSS class for separators. |
| `subContentClassName` | `string` | `undefined` | CSS class for sub-menu containers. |
| `features` | `{ copy, export, pin, reset }` | All enabled | Which default features to show. |

### Row-menu features (inline shape)

| Property | Type | Default | Description |
| --- | --- | --- | --- |
| `copy` | `boolean` | `true` | The copy actions, named by scope (selection, cell, row, selected rows). Also the copy-only menu of a generated row. |
| `export` | `boolean` | `true` | Export section (CSV, Excel, JSON). |
| `pin` | `boolean` | `true` | Row pinning options. |
| `reset` | `boolean` | `true` | Reset table layout. |
