Active Foundation

Search documentation

Search all pages, headings and text, in the current package or topic or in all documentation.

Grid props

Every prop of ActiveGrid and GridDefaults, with its type and default.

@workspace/active-grid 11.0.0Status unknowncontent differs from the recorded release 11.0.0baseline ce081dc3

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.

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

PropTypeDefaultDescription
gridIdstring(required)Unique identifier. Used for persistence keys and URL sync.
columnsGridColumnDef<TData>[](required)Column definitions array.
heightnumber | 'auto'400Grid height. Number for fixed pixels with scroll; 'auto' for content-driven height.
enablePersistencebooleanfalsePersist grid state (sorting, filters, sizing) to localStorage. Restored state is structurally validated — see State persistence.
enableUrlSyncbooleanfalseSync grid state to URL query parameters. The grid owns exactly five keys and leaves every other query parameter alone — see URL state.
settingsGridSettings<TData>undefinedVisual settings (row height, striping, footer styling).
titlestringundefinedGrid title (displayed in toolbar).
descriptionstringundefinedGrid description.
searchPlaceholderstring'Search...'Placeholder text for the global search input.
toolbarGridToolbarConfig<TData>undefinedToolbar configuration (search, actions).
paginationGridPaginationOptions{enabled: true}Pagination configuration.
headerMenuGridHeaderMenuConfig{enabled: true}Per-column header menu configuration.
rowMenuGridRowMenuConfigundefinedRow context menu configuration.
exportboolean | GridExportOptionstrueExport 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) => voidundefinedCallback when a row is clicked.
onRowDoubleClick(row: TData) => voidundefinedCallback when a row is double-clicked.
onCellClick(row: TData, columnId: string, value: unknown) => voidundefinedFires on cell click with row data, column ID, and value. Both onCellClick and onRowClick fire on the same click (cell first via event bubbling).
contextMenuContextMenuBuilder<TData>undefinedCustom context menu builder: (row, context?) => items, called for a source row when its menu opens; context is a ContextMenuContext.
classNamestringundefinedAdditional CSS class on the grid container.
columnDefaultsGridColumnDefaults<TData>undefinedDefault values applied to all columns.
columnTypesGridColumnTypes<TData>undefinedExtends or overrides the built-in column types per type name; the built-in types apply without it (ADR-045).
initialPageSizenumber10Page 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.
enableColumnResizingbooleantrueAllow 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.
enableColumnPinningbooleantrueAllow columns to be pinned left/right.
enableColumnReorderingbooleanfalseAllow 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).
enableKeyboardNavigationbooleantrueArrow-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) => voidundefinedFires when Enter/Space activates a focused cell. Not fired for editable cells — there Enter starts the edit instead.
defaultMinSizenumber120Default minimum column width in pixels. Overrides global columns.minWidth for this grid.
minGridWidthnumberundefinedMinimum 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.
enableRowPinningbooleanfalseAllow rows to be pinned to top/bottom.
isRowPinnable(row: TData) => booleanundefinedPredicate to control which rows can be pinned.
maxPinnedRowsnumber10Maximum number of pinned rows.
getRowId(row: TData) => stringAuto-detect id fieldCustom 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.
enableExpandingbooleanfalseAllow rows to be expanded to show detail panels.
renderDetailPanel(row: Row<TData>) => ReactNodeundefinedRender function for the expanded detail panel.
getRowCanExpand(row: Row<TData>) => booleanundefinedPredicate to control which rows can be expanded.
showRowNumbersbooleanfalseShow auto-incrementing row numbers as the first column.
enableGroupingbooleanfalseEnable 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.
initialGroupingGroupingState (string[])[]Initial grouping columns. Applied once on mount.
onGroupingChange(grouping: GroupingState) => voidundefinedCallback 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).
commitOnBlurbooleanfalseWhen 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>undefinedCallback when edited data is committed. Required for editMode="cell" / "row" — without it those modes offer no editing.
onBatchCommit(payload: BatchCommitPayload<TData>) => Promise<TData[] | void>undefinedCallback 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.
autoSizeOnMountboolean | Partial<AutoSizeOnMountOptions>falseAuto-size columns when grid mounts and data is available.
tGridTranslateFunctioninheritedThis 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).
localeGridPresentationinheritedThis 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'inheritedThe 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.
metaPartial<GridMeta<TData>>undefinedCustom metadata passed to the grid instance. Used internally for editing-state, pinning-state, copy-callbacks (onCopySuccess, onCopyError), aggregations, facets, and more.
virtualizationbooleanundefinedControls row virtualization. true forces on, false forces off, omit for auto-detect.
virtualizationThresholdnumber1000Row count above which virtualization is enabled (when virtualization is not set and height is a number).
enableRowSelectionboolean or (row: Row<TData>) => booleanfalseEnable selection and its checkbox column; a predicate receives a source/generated row and must narrow before reading original.
onSelectionChange(rows: TData[]) => voidundefinedCallback with selected row data on every selection change.
gridRefRef<GridImperativeHandle>undefinedImperative handle. Production consumer code should use useGridMutation(gridRef) instead of calling its methods directly — see server-mode.md and ADR-009.
restoreFocusRefReact.MutableRefObject<(() => boolean) | null>undefinedRef 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.
serverQueryKeystringundefinedOpaque 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.
serverOptionsServerOptionsundefinedServer-mode options: page caching, prefetch, block size. Only applies when mode="server". See server-mode.md.
enableMultiRowSelectionbooleantrueAllow 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) => voidundefinedStructural selection callback. Receives SelectionModel discriminated union (inclusive / exclusive). See ../guides/selection.md.
selectionActionsReadonlyArray<GridSelectionAction>undefinedDeclarative 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 and ../guides/selection.md.
onSortingChange(sorting: SortingState) => voidundefinedCallback fired when sort state changes. Receives the new sort spec.
onFilterChange(filters: ColumnFiltersState) => voidundefinedCallback fired when column-filter state changes.
enableColumnVirtualizationbooleanfalseEnable 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.
loadingDisplayLoadingIndicator | LoadingDisplayOptionssee belowWhat the grid shows while a fetch is in flight. Nothing on first load; the 2-pixel bar on a refetch. See Loading display.
externalFilter(row: TData) => booleanundefinedConsumer-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.
postSortRows(rows: TData[]) => TData[]undefinedRe-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.
enableGroupFooterbooleanfalseRender a subtotal/footer-row at the end of each group (client-side grouping only).
enableServerGroupingbooleanfalseEnable server-side grouping mode. Server returns isGrouped and groups metadata; the outer grouping hook constructs synthetic display rows. See ../guides/server-grouping.md.
serverGroupByGroupByColumn[]undefinedOrdered {field, sort?} declarations passed to fetchFn as params.groupBy.
enableResponsiveHidingbooleanfalseHide columns automatically based on viewport breakpoints. Use with responsiveBreakpoints to declare hide-thresholds per column.
responsiveBreakpointsRecord<string, number>undefinedColumn-id → minimum viewport width (px) below which the column is hidden.
enableCellSelectionbooleanfalseCell-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[]) => voidundefinedCalled 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 [].
enableFilterBuilderbooleanfalseShow 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.
initialFilterExpressionFilterExpressionundefinedInitial 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) => voidundefinedCallback when the filter-builder tree changes. Never fires in server mode, where no expression can be composed.
enableUndoRedobooleantrue with onDataCommit, else falseEnable 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.
maxUndoDepthnumber50Maximum 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#

TypeScript
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:

TypeScript
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#

ValueWhat you seeWhen 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.
ReactNodeWhatever 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:

PropServer-mode behaviourWhy
externalFilterIgnored, dev-warns once per gridThere 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.
enableFilterBuilderToolbar button and panel are not rendered; dev-warns once per gridA 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:

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

Sources 1 and 2 are ordered against each other by statePriority (default 'url-first') — see 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 storedvalue does not match the expected shape (received an array of 2). The slicefalls 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:

KeyGrid stateShape
pagepagination.pageIndex1-indexed (page=3 is pageIndex: 2)
pageSizepagination.pageSizeomitted when it equals the package default of 10
sortsortingname:asc,age:desc
filterscolumnFilterssalary:greaterThan:50000;type:in:city,village
searchglobalFilterURL-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';
MethodDescription
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.

PropertyTypeDefaultDescription
idstring—Stable identity. Never a translated label.
labelstring—Visible text, already translated by the consumer.
iconReact.ComponentType<{className?: string}>undefinedOptional leading icon component.
toneGridSelectionActionTone ('default' | 'danger')'default'danger renders the destructive menu-item presentation.
separatorBeforebooleanfalseDraw a separator above this item.
supportsDatasetWideSelectionbooleantrueWhether 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:

PropertyTypeDefaultDescription
rowHeightnumber42Row height in pixels.
headerHeightnumber48Header height.
groupHeaderHeightnumber32Group header height.
filterBarHeightnumber36Filter bar height.
paginationHeightnumberundefinedPagination bar height.
stripedRowsbooleanfalseEnable alternating row colors.
stripeClassNamestringundefinedCustom class for striped rows.
showColumnBordersboolean | 'none' | 'partial' | 'full''none'Column border mode. 'partial' = 50% height, 'full' = 100%. true/false are backwards-compatible aliases.
autoSizeMaxBodyWidthnumberundefinedMax width (px) that body-cell content may contribute to auto-size. Header width always wins. Per-column override via meta.autoSizeMaxBodyWidth.
disableHoverEffectbooleanundefinedDisable row hover background.
noDataMessagestring'No results found.'Message when grid has no data.
noDataOverlayReactNodeundefinedCustom empty-state component.
rowClassRulesRecord<string, (data: TData) => boolean>undefinedConditional CSS classes on rows.
showFilterRowbooleanundefinedFilter row visibility: true = always show, false = always hide, undefined = auto (show when ≥1 column has meta.filterType).
footerGridFooterConfigundefinedFooter styling.
headerGridHeaderConfigundefinedHeader styling and behavior.
paginationGridPaginationStyleConfigundefinedPagination styling (containerClassName).

GridHeaderConfig#

PropertyTypeDefaultDescription
filterRowClassNamestringundefinedCSS class for the filter row.
menuPosition'left' | 'right''right'Position of the menu button within header cells.
showMenuOnHoverbooleantrueShow menu trigger only on hover (true) or always visible (false).
showFilterIconbooleantrueShow filter-active icon in header cells when column has an active filter.
showColumnSeparatorsbooleantrueShow column separator lines between header cells (controlled by CSS vars).

GridFooterConfig#

PropertyTypeDefaultDescription
classNamestringundefinedCSS class for the footer container row (background, borders, etc).
cellClassNamestringundefinedCSS class for individual footer cells.

GridPaginationOptions#

PropertyTypeDefaultDescription
enabledbooleantrueEnable 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.
pageSizeOptionsnumber[][10, 20, 50, 100]Available page sizes.
showRowCountbooleantrueShow "1 – 50 of 5,000" row-count summary.
showPageSizeSelectorbooleantrueShow page-size selector dropdown.
showPageIndicatorbooleantrueShow "Page 1 of 100" page indicator.
showFirstLastButtonsbooleantrueShow first/last page buttons (« »).
showPrevNextButtonsbooleantrueShow prev/next page buttons (‹ ›).
rowCountPosition'left' | 'right''left'Position of row-count summary.
classNamestringundefinedAdditional 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).

PropertyTypeDefaultDescription
hiddenbooleanundefinedForce-hide the toolbar.
classNamestringundefinedCSS class on the toolbar container.
search{ enabled, placeholder, className, debounceMs }undefinedSearch input configuration (inline shape). See below.
showColumnChooserbooleanfalseShow the Column Chooser button. Opens a dialog for toggling column visibility. Appears only when at least one column has enableHiding: true.
actionsReactNode | ((grid: Grid<TData>) => ReactNode)undefinedCustom toolbar actions (buttons, dropdowns).

Search input (inline shape)#

PropertyTypeDefaultDescription
enabledbooleanfalseEnable the search bar in the toolbar.
placeholderstring'Search...'Search input placeholder text.
classNamestringundefinedCSS class on the search input.
debounceMsnumber300Debounce 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.

PropertyTypeDefaultDescription
enabledbooleantrueWhether the menu is enabled.
showLabelsbooleanfalseShow text labels above sections (e.g. "SORT").
classNamestringundefinedCSS class for the dropdown container.
itemClassNamestringundefinedCSS class for individual menu items.
separatorClassNamestringundefinedCSS class for separators.
features{ sort, pin, resize, visibility, grouping, reset }All enabledWhich features to show. See below.

Header-menu features (inline shape)#

PropertyTypeDefaultDescription
sortbooleantrueAscending/Descending options.
pinbooleantruePin left/right options.
resizebooleantrueAuto-size options.
visibilitybooleantrueHide column option.
groupingbooleantrueGroup-by column (only when enableGrouping is on).
resetbooleantrueReset table layout.

GridRowMenuConfig#

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

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

PropertyTypeDefaultDescription
enabledbooleantrueWhether the context menu is enabled. false offers no grid menu on any row; the browser keeps its own.
classNamestringundefinedCSS class for the menu container.
itemClassNamestringundefinedCSS class for individual items.
separatorClassNamestringundefinedCSS class for separators.
subContentClassNamestringundefinedCSS class for sub-menu containers.
features{ copy, export, pin, reset }All enabledWhich default features to show.

Row-menu features (inline shape)#

PropertyTypeDefaultDescription
copybooleantrueThe copy actions, named by scope (selection, cell, row, selected rows). Also the copy-only menu of a generated row.
exportbooleantrueExport section (CSV, Excel, JSON).
pinbooleantrueRow pinning options.
resetbooleantrueReset table layout.

This page as Markdown