gridcn
blockdata-grid-url-statesource: ok · hit
Data Grid URL State
Syncs the gridcn data grid's sort/filter/search state with the URL via nuqs (router-agnostic — requires a NuqsAdapter above it). Mount <DataGridUrlState prefix?> inside the provider; the prefix option namespaces the URL keys for multiple grids on one page.
npx shadcn add "https://gridcn.vercel.app/r/data-grid-url-state.json"free
Where it goes
stats
Interaction
animated-counter
Tags auto-generated by rules on 9/24/2026 — empty means no evidence, not lack of support.
Dependencies
Bundle weight · lightnuqs
Registry dependencies
DammersCode/gridcn/data-grid
Files9 · source cached 10/8/2026, 1:40:10 PM
registry/default/blocks/data-grid-url-state/data-grid-url-state.tsx
"use client";
import type { ReactNode } from "react";
import { useDataGridUrlState, type UseDataGridUrlStateOptions } from "./use-data-grid-url-state";
/** Public entry point — the only module consumers/docs should import from. */
export {
useDataGridUrlState,
type UseDataGridUrlStateOptions,
type UseDataGridUrlStateResult,
} from "./use-data-grid-url-state";
export {
useDataGridUrlPagination,
type UseDataGridUrlPaginationOptions,
type UseDataGridUrlPaginationResult,
} from "./use-data-grid-url-pagination";
export { serializeSortState, parseSortState } from "./sort-param";
export { serializeFilterState, parseFilterState } from "./filter-param";
export { serializeJoinOperator, parseJoinOperator } from "./join-param";
export { serializePage, parsePage, serializePageSize, parsePageSize, DEFAULT_URL_PAGE_SIZE } from "./page-param";
export { FILTER_OPERATORS } from "./filter-operators";
export { prefixedKey } from "./prefixed-key";
/** Props for {@link DataGridUrlState}. */
export type DataGridUrlStateProps = UseDataGridUrlStateOptions;
/**
* Renders nothing; syncs the grid's sort/filter/search state with the URL (see
* {@link useDataGridUrlState}). Mount it once inside `<DataGridProvider>`, alongside
* `<DataGridRoot>`/`<DataGridToolbar>`. Requires a nuqs adapter (`NuqsAdapter` from
* `nuqs/adapters/react`, or your framework's) somewhere above it in the tree.
*
* @example
* <DataGridProvider data={rows} columns={columns} getRowId={(r) => r.id}>
* <DataGridUrlState prefix="orders" />
* <DataGridToolbar><DataGridSearch /></DataGridToolbar>
* <DataGridRoot>...</DataGridRoot>
* </DataGridProvider>
*/
export function DataGridUrlState(props: DataGridUrlStateProps): ReactNode {
useDataGridUrlState(props);
return null;
}
registry/default/blocks/data-grid-url-state/filter-operators.ts
import type { FilterOperator } from "@/registry/default/components/data-grid/data-grid";
/** The closed set of valid {@link FilterOperator} values, for validating URL input against. */
export const FILTER_OPERATORS: ReadonlySet<FilterOperator> = new Set([
"contains",
"notContains",
"equals",
"notEquals",
"startsWith",
"endsWith",
"empty",
"notEmpty",
"gt",
"gte",
"lt",
"lte",
"isBetween",
"isAnyOf",
] satisfies FilterOperator[]);
/** Narrows an untrusted (URL-decoded) string to {@link FilterOperator}. */
export function isFilterOperator(value: string): value is FilterOperator {
return FILTER_OPERATORS.has(value as FilterOperator);
}
registry/default/blocks/data-grid-url-state/filter-param.ts
import type { FilterOperator, FilterSpec } from "@/registry/default/components/data-grid/data-grid";
import { isFilterOperator } from "./filter-operators";
/** In-value separator for `isBetween`'s two-part value. `encodeURIComponent` leaves `~` in its unreserved set untouched, so a literal `~` inside a bound must be escaped separately (see {@link encodeRangePart}) to stay unambiguous against the separator. */
const RANGE_SEPARATOR = "~";
/** In-value separator for `isAnyOf`'s choice list. `encodeURIComponent` escapes a literal `|` inside a choice to `%7C`, so the raw separator can never collide with a choice's own characters. */
const ANYOF_SEPARATOR = "|";
/** `encodeURIComponent` plus escaping the one character it doesn't (`~`), so a literal tilde in a bound never collides with {@link RANGE_SEPARATOR}. */
function encodeRangePart(part: string): string {
return encodeURIComponent(part).replace(/~/g, "%7E");
}
function encodeFilterValue(operator: FilterOperator, value: FilterSpec["value"]): string {
if (value === undefined) return "";
if (Array.isArray(value) && operator === "isBetween") return `${encodeRangePart(value[0])}${RANGE_SEPARATOR}${encodeRangePart(value[1])}`;
if (Array.isArray(value)) return value.map(encodeURIComponent).join(ANYOF_SEPARATOR);
return encodeURIComponent(value);
}
/**
* Compact URL encoding for {@link FilterSpec}[]: comma-separated `columnId:operator:value` triples
* (`value` omitted for operators with none, e.g. `empty`/`notEmpty`), column id and value
* percent-encoded. `isBetween`'s two-value range encodes as `min~max`, `isAnyOf`'s choice list as
* `a|b|c` (each choice percent-encoded, so the raw `|` separator can never collide) — both still
* one `value` field, so every other operator's format — and any URL generated before those
* operators existed — round-trips unchanged. `filterId` is never serialized — it's a transient
* list-rendering identity the store re-derives on parse, not part of the filter's meaning.
*
* @example serializeFilterState([{ columnId: "age", operator: "gt", value: "30" }])
* // => "age:gt:30"
* @example serializeFilterState([{ columnId: "name", operator: "empty" }])
* // => "name:empty:"
* @example serializeFilterState([{ columnId: "age", operator: "isBetween", value: ["10", "30"] }])
* // => "age:isBetween:10~30"
* @example serializeFilterState([{ columnId: "status", operator: "isAnyOf", value: ["new", "open"] }])
* // => "status:isAnyOf:new|open"
*/
export function serializeFilterState(filters: readonly FilterSpec[]): string {
return filters.map((f) => `${encodeURIComponent(f.columnId)}:${f.operator}:${encodeFilterValue(f.operator, f.value)}`).join(",");
}
/** `decodeURIComponent` throws on malformed `%` sequences a hand-edited URL can contain — never let that surface. */
function safeDecode(value: string): string | null {
try {
return decodeURIComponent(value);
} catch {
return null;
}
}
/** Sentinel distinguishing "decode failed, drop the segment" from a legitimately-empty decoded value (`""`). */
const DECODE_FAILED = Symbol("decode-failed");
/** Decodes a segment's value part per `operator`: `isBetween` splits on {@link RANGE_SEPARATOR} into a tuple, `isAnyOf` splits on {@link ANYOF_SEPARATOR} into a choice list (empty value = empty list), everything else stays a plain string. Malformed input (wrong tuple arity, bad percent-encoding) returns {@link DECODE_FAILED}. */
function decodeFilterValue(operator: string, raw: string): FilterSpec["value"] | typeof DECODE_FAILED {
if (operator === "isAnyOf") {
if (raw === "") return [];
const parts: string[] = [];
for (const rawPart of raw.split(ANYOF_SEPARATOR)) {
const decoded = safeDecode(rawPart);
if (decoded === null) return DECODE_FAILED;
parts.push(decoded);
}
return parts;
}
if (operator !== "isBetween") {
const decoded = safeDecode(raw);
return decoded === null ? DECODE_FAILED : decoded;
}
const parts = raw.split(RANGE_SEPARATOR);
if (parts.length !== 2) return DECODE_FAILED;
const [min, max] = parts.map(safeDecode);
if (min == null || max == null) return DECODE_FAILED;
return [min, max];
}
/**
* Inverse of {@link serializeFilterState}. Malformed segments (unknown operator, empty/undecodable
* column id, wrong field count, malformed `isBetween` range or `isAnyOf` list) are dropped silently
* rather than throwing — a corrupt/hand-edited URL degrades to fewer filters, never an error.
* Every filter parses without a `filterId`; the store backfills a fresh stable one on the next sync.
*
* @example parseFilterState("age:gt:30") // => [{ columnId: "age", operator: "gt", value: "30" }]
* @example parseFilterState("name:empty:") // => [{ columnId: "name", operator: "empty", value: "" }]
* @example parseFilterState("age:isBetween:10~30") // => [{ columnId: "age", operator: "isBetween", value: ["10", "30"] }]
* @example parseFilterState("status:isAnyOf:new|open") // => [{ columnId: "status", operator: "isAnyOf", value: ["new", "open"] }]
* @example parseFilterState("garbage") // => []
*/
export function parseFilterState(raw: string): FilterSpec[] {
if (!raw) return [];
const filters: FilterSpec[] = [];
for (const segment of raw.split(",")) {
const parts = segment.split(":");
if (parts.length !== 3) continue;
const [columnId, operator, rawValue] = parts;
if (!columnId || operator === undefined || rawValue === undefined || !isFilterOperator(operator)) continue;
const decodedId = safeDecode(columnId);
if (decodedId === null || decodedId === "") continue;
const value = decodeFilterValue(operator, rawValue);
if (value === DECODE_FAILED) continue;
filters.push({ columnId: decodedId, operator, value });
}
return filters;
}
registry/default/blocks/data-grid-url-state/join-param.ts
import type { FilterJoinOperator } from "@/registry/default/components/data-grid/data-grid";
/**
* URL encoding for {@link FilterJoinOperator}: the literal value itself, no extra structure needed.
* A separate `join` param (not folded into `filter`) so an "and"-only URL from before this feature
* existed keeps parsing identically — {@link parseJoinOperator} treats an absent/malformed param the
* same as `"and"`, the pre-existing default.
*
* @example serializeJoinOperator("or") // => "or"
* @example serializeJoinOperator("and") // => "" (the default; omitted from the URL)
*/
export function serializeJoinOperator(joinOperator: FilterJoinOperator): string {
return joinOperator === "or" ? "or" : "";
}
/**
* Inverse of {@link serializeJoinOperator}. Anything other than exactly `"or"` (absent, empty,
* hand-edited garbage) parses as `"and"` — never throws.
*
* @example parseJoinOperator("or") // => "or"
* @example parseJoinOperator("") // => "and"
* @example parseJoinOperator("garbage") // => "and"
*/
export function parseJoinOperator(raw: string): FilterJoinOperator {
return raw === "or" ? "or" : "and";
}
registry/default/blocks/data-grid-url-state/page-param.ts
/** Default `pageSize` when {@link UseDataGridUrlPaginationOptions.defaultPageSize} is not given. */
export const DEFAULT_URL_PAGE_SIZE = 25;
/**
* URL encoding for the 1-based `page` number: the plain decimal string, omitted entirely when
* `page` is 1 (the default), matching `join`'s omit-at-default convention.
*
* @example serializePage(1) // => "" (the default; omitted from the URL)
* @example serializePage(3) // => "3"
*/
export function serializePage(page: number): string {
return page <= 1 ? "" : String(page);
}
/**
* Inverse of {@link serializePage}. Anything that isn't a positive integer (absent, empty,
* negative, fractional, hand-edited garbage) parses as `1` — never throws.
*
* @example parsePage("3") // => 3
* @example parsePage("") // => 1
* @example parsePage("garbage") // => 1
*/
export function parsePage(raw: string): number {
const n = Number(raw);
return raw !== "" && Number.isInteger(n) && n >= 1 ? n : 1;
}
/**
* URL encoding for `pageSize`: the plain decimal string, omitted when it equals `defaultPageSize`.
*
* @example serializePageSize(25, 25) // => "" (matches the default; omitted from the URL)
* @example serializePageSize(50, 25) // => "50"
*/
export function serializePageSize(pageSize: number, defaultPageSize: number): string {
return pageSize === defaultPageSize ? "" : String(pageSize);
}
/**
* Inverse of {@link serializePageSize}. Falls back to `defaultPageSize` for anything that isn't a
* positive integer. When `allowedSizes` is given, a parsed value outside that list also falls back
* to `defaultPageSize` — the URL cannot force a page size the consumer didn't offer.
*
* @example parsePageSize("50", 25) // => 50
* @example parsePageSize("", 25) // => 25
* @example parsePageSize("999", 25, [10, 25, 50]) // => 25 (not in the allowed list)
*/
export function parsePageSize(raw: string, defaultPageSize: number, allowedSizes?: readonly number[]): number {
const n = Number(raw);
if (raw === "" || !Number.isInteger(n) || n < 1) return defaultPageSize;
if (allowedSizes && !allowedSizes.includes(n)) return defaultPageSize;
return n;
}
registry/default/blocks/data-grid-url-state/prefixed-key.ts
/** Namespaces a URL param key for multi-grid pages, e.g. `prefixedKey("orders", "sort")` => `"orders_sort"`. */
export function prefixedKey(prefix: string | undefined, key: string): string {
return prefix ? `${prefix}_${key}` : key;
}
registry/default/blocks/data-grid-url-state/sort-param.ts
import type { SortSpec } from "@/registry/default/components/data-grid/data-grid";
/**
* Compact URL encoding for {@link SortSpec}[]: comma-separated `columnId:direction` pairs,
* column id percent-encoded (ids are consumer-controlled and may contain `,`/`:`).
*
* @example serializeSortState([{ columnId: "name", direction: "asc" }, { columnId: "age", direction: "desc" }])
* // => "name:asc,age:desc"
*/
export function serializeSortState(sorts: readonly SortSpec[]): string {
return sorts.map((s) => `${encodeURIComponent(s.columnId)}:${s.direction}`).join(",");
}
/** `decodeURIComponent` throws on malformed `%` sequences a hand-edited URL can contain — never let that surface. */
function safeDecode(value: string): string | null {
try {
return decodeURIComponent(value);
} catch {
return null;
}
}
/**
* Inverse of {@link serializeSortState}. Malformed segments (missing direction, bad direction
* value, empty/undecodable column id) are dropped silently rather than throwing — a corrupt/hand-edited
* URL degrades to fewer sorts, never an error.
*
* @example parseSortState("name:asc,age:desc") // => [{ columnId: "name", direction: "asc" }, { columnId: "age", direction: "desc" }]
* @example parseSortState("garbage") // => []
*/
export function parseSortState(raw: string): SortSpec[] {
if (!raw) return [];
const sorts: SortSpec[] = [];
for (const segment of raw.split(",")) {
const [columnId, direction] = segment.split(":");
if (!columnId || (direction !== "asc" && direction !== "desc")) continue;
const decoded = safeDecode(columnId);
if (decoded === null || decoded === "") continue;
sorts.push({ columnId: decoded, direction });
}
return sorts;
}
registry/default/blocks/data-grid-url-state/use-data-grid-url-pagination.ts
"use client";
import { useCallback, useEffect, useMemo, useRef } from "react";
import { useQueryState } from "nuqs";
import { clampPage, DEFAULT_PAGE_SIZES } from "@/registry/default/components/data-grid-pagination/data-grid-pagination";
import { parsePage, parsePageSize, serializePage, serializePageSize, DEFAULT_URL_PAGE_SIZE } from "./page-param";
import { prefixedKey } from "./prefixed-key";
/** Options for {@link useDataGridUrlPagination}. */
export type UseDataGridUrlPaginationOptions = {
/** Namespaces the `page`/`pageSize` URL keys so multiple grids can coexist on one page. */
prefix?: string;
/** `pageSize` this many is omitted from the URL. Also the fallback for an invalid URL value. Default 25. */
defaultPageSize?: number;
/** When given, a `pageSize` URL value outside this list falls back to `defaultPageSize` instead of being trusted. */
pageSizeOptions?: readonly number[];
/** The dataset's row count; when given, an out-of-range deep-linked `page` is clamped to the last page and the clamped value is written back to the URL on mount. */
total?: number;
};
/** Controlled pair spread straight into `useDataGridPagination`'s server mode. */
export type UseDataGridUrlPaginationResult = {
page: number;
pageSize: number;
/** The `pageSizeOptions` the URL parsing enforces (`pageSizeOptions` or the pager default) — include it so the bar's select and the URL allow-list never diverge. */
pageSizeOptions: readonly number[];
onPageChange: (page: number) => void;
onPageSizeChange: (pageSize: number) => void;
};
/**
* Syncs `page`/`pageSize` with the URL via nuqs, no dependency on `data-grid-pagination` or the
* grid's store — pagination has no store slot, so this hook just owns two URL-backed numbers and
* hands back the controlled pair `useDataGridPagination` already accepts in server mode:
*
* @example
* const url = useDataGridUrlPagination();
* const pager = useDataGridPagination({ total: rows.length, pageSizeOptions: url.pageSizeOptions, ...url });
*
* Pass `total` so an out-of-range deep-linked `page` clamps to the last page on mount and the
* clamped value replaces the raw one in the URL.
*
* `page` is 1-based and omitted from the URL at 1; `pageSize` is omitted when it equals
* `defaultPageSize`. Both writes use `history: "replace"`, matching this add-on's sort/filter/
* search params — paging through results never pollutes back/forward history. Invalid or
* out-of-range URL values (negative, fractional, a `pageSize` outside `pageSizeOptions`) clamp to
* the default rather than throwing, the same posture as this add-on's other parsers.
*/
export function useDataGridUrlPagination(options: UseDataGridUrlPaginationOptions = {}): UseDataGridUrlPaginationResult {
const { prefix, defaultPageSize = DEFAULT_URL_PAGE_SIZE, pageSizeOptions, total } = options;
const [pageParam, setPageParam] = useQueryState(prefixedKey(prefix, "page"), {
defaultValue: "",
parse: (v) => v,
serialize: (v) => v,
history: "replace",
});
const [pageSizeParam, setPageSizeParam] = useQueryState(prefixedKey(prefix, "pageSize"), {
defaultValue: "",
parse: (v) => v,
serialize: (v) => v,
history: "replace",
});
const rawPage = parsePage(pageParam);
const pageSize = parsePageSize(pageSizeParam, defaultPageSize, pageSizeOptions);
const page = total !== undefined ? clampPage(rawPage, total, pageSize) : rawPage;
const resolvedPageSizeOptions: readonly number[] = pageSizeOptions ?? DEFAULT_PAGE_SIZES;
// normalize once, when the first total is known: a deep-linked ?page=999 stays raw in the URL (shared
// link disagrees with the rendered view, and "revives" if the dataset grows) unless the clamped value is written back
const normalizedRef = useRef(false);
useEffect(() => {
if (normalizedRef.current || total === undefined) return;
normalizedRef.current = true;
const clamped = clampPage(parsePage(pageParam), total, parsePageSize(pageSizeParam, defaultPageSize, pageSizeOptions));
const serialized = serializePage(clamped) || "";
if (pageParam !== serialized) void setPageParam(serialized || null);
// oxlint-disable-next-line react-hooks/exhaustive-deps
}, [total]);
const onPageChange = useCallback(
(next: number) => {
void setPageParam(serializePage(next) || null);
},
[setPageParam],
);
const onPageSizeChange = useCallback(
(next: number) => {
void setPageSizeParam(serializePageSize(next, defaultPageSize) || null);
// A page number is only valid for the size it was chosen at: a page-size change resets the page to 1 (the URL composition's contract).
void setPageParam(null);
},
[setPageSizeParam, setPageParam, defaultPageSize],
);
return useMemo(
() => ({ page, pageSize, pageSizeOptions: resolvedPageSizeOptions, onPageChange, onPageSizeChange }),
[page, pageSize, resolvedPageSizeOptions, onPageChange, onPageSizeChange],
);
}
registry/default/blocks/data-grid-url-state/use-data-grid-url-state.ts
"use client";
import { useCallback, useEffect, useRef } from "react";
import { useQueryState } from "nuqs";
import {
isDev,
useDataGridActions,
useDataGridAllColumns,
useDataGridFilterState,
useDataGridJoinOperator,
useDataGridSearchText,
useDataGridSortState,
} from "@/registry/default/components/data-grid/data-grid";
import { serializeSortState, parseSortState } from "./sort-param";
import { serializeFilterState, parseFilterState } from "./filter-param";
import { serializeJoinOperator, parseJoinOperator } from "./join-param";
import { prefixedKey } from "./prefixed-key";
/** Debounce (ms) between typing in the quick-search box and writing `searchText` to the URL. */
const SEARCH_WRITE_DEBOUNCE_MS = 300;
/** Dev-only warning, core's `warnDev` pattern with this add-on's own prefix (import-boundary rule: add-ons import only from core's block-root barrel). */
function warnDev(message: string): void {
if (isDev()) console.warn(`[data-grid-url-state] ${message}`);
}
/** Options for {@link useDataGridUrlState}. */
export type UseDataGridUrlStateOptions = {
/** Namespaces the `sort`/`filter`/`join`/`q` URL keys so multiple grids can coexist on one page. */
prefix?: string;
};
/** Result of {@link useDataGridUrlState}. */
export type UseDataGridUrlStateResult = {
/**
* Re-applies the current URL params to the store, including the dropped-column warning. Unlike
* the mount apply, a param missing from the URL clears its sort, filter, join, or search. Call
* it when the URL changed outside the grid's own writes —
* back/forward, a framework `setSearchParams`, or a query-only route change — since mount
* applies exactly once and the store is the source of truth afterwards.
*/
applyFromUrl: () => void;
};
/**
* Syncs the grid store's `sortState`/`filterState`/`joinOperator`/`searchText` with the URL via
* nuqs: applies any URL params to the store once on mount, then writes store changes back to the
* URL (replacing, never pushing, history entries). Requires a `NuqsAdapter` (or the testing
* adapter) above it in the tree — nuqs itself is router-agnostic. Search writes are debounced so
* typing doesn't spam `history.replaceState`; sort/filter/join writes (menu-driven, not
* keystroke-driven) are immediate. `joinOperator` gets its own `join` param rather than folding
* into `filter` so a URL generated before this feature existed keeps parsing identically.
*
* The URL→store direction runs once on mount; after that the store wins. `applyFromUrl()`
* re-runs the mount apply when the URL changed outside the grid's own writes.
*/
export function useDataGridUrlState(options: UseDataGridUrlStateOptions = {}): UseDataGridUrlStateResult {
const { prefix } = options;
const actions = useDataGridActions();
const allColumns = useDataGridAllColumns();
const sortState = useDataGridSortState();
const filterState = useDataGridFilterState();
const joinOperator = useDataGridJoinOperator();
const searchText = useDataGridSearchText();
const [sortParam, setSortParam] = useQueryState(prefixedKey(prefix, "sort"), {
defaultValue: "",
parse: (v) => v,
serialize: (v) => v,
history: "replace",
});
const [filterParam, setFilterParam] = useQueryState(prefixedKey(prefix, "filter"), {
defaultValue: "",
parse: (v) => v,
serialize: (v) => v,
history: "replace",
});
const [joinParam, setJoinParam] = useQueryState(prefixedKey(prefix, "join"), {
defaultValue: "",
parse: (v) => v,
serialize: (v) => v,
history: "replace",
});
const [searchParam, setSearchParam] = useQueryState(prefixedKey(prefix, "q"), {
defaultValue: "",
parse: (v) => v,
serialize: (v) => v,
history: "replace",
});
// The nuqs setters are NOT stable across external URL changes (back/forward, setSearchParams,
// the testing adapter's searchParams prop) — the adapter re-renders and the query hook rebuilds
// its updater. They must stay out of the write-back effects' dep lists, or an external URL change
// re-triggers the write-back and stomps the new URL value before applyFromUrl can read it. The
// refs keep a live handle to the latest setter so the effects only re-run when the STORE state
// (the actual source of truth) changes.
const setSortParamRef = useRef(setSortParam);
setSortParamRef.current = setSortParam;
const setFilterParamRef = useRef(setFilterParam);
setFilterParamRef.current = setFilterParam;
const setJoinParamRef = useRef(setJoinParam);
setJoinParamRef.current = setJoinParam;
const setSearchParamRef = useRef(setSearchParam);
setSearchParamRef.current = setSearchParam;
// Reads the current URL params and applies them to the store. Runs once on mount (via the
// appliedRef guard below) and is exposed as applyFromUrl so a consumer can re-apply after an
// external URL change (back/forward, setSearchParams, query-only route change).
const applyUrl = useCallback((keepMountDefaults: boolean) => {
// Drop specs referencing a column no longer in the grid (a shared link outlived a removal) and dev-warn each one.
const liveColumnIds = new Set(allColumns.map((c) => c.id));
const parsedSorts = parseSortState(sortParam);
const sorts = parsedSorts.filter((spec) => {
if (liveColumnIds.has(spec.columnId)) return true;
warnDev(`dropped URL sort spec for removed column "${spec.columnId}"`);
return false;
});
const parsedFilters = parseFilterState(filterParam);
const filters = parsedFilters.filter((spec) => {
if (liveColumnIds.has(spec.columnId)) return true;
warnDev(`dropped URL filter spec for removed column "${spec.columnId}"`);
return false;
});
const join = parseJoinOperator(joinParam);
// On mount an absent param keeps the grid's initial state; a re-apply mirrors the URL, so a removed param clears its state.
if (sorts.length > 0 || !keepMountDefaults) actions.setSorts(sorts);
if (filters.length > 0 || !keepMountDefaults) actions.setFilters(filters);
if (join !== "and" || !keepMountDefaults) actions.setJoinOperator(join);
if (searchParam || !keepMountDefaults) actions.setSearch(searchParam);
}, [actions, allColumns, sortParam, filterParam, joinParam, searchParam]);
const applyFromUrl = useCallback(() => applyUrl(false), [applyUrl]);
// apply URL -> store exactly once on mount; afterwards the store is the source of truth and
// this effect's own writes below must not be re-read back as if they were external URL edits.
const appliedRef = useRef(false);
useEffect(() => {
if (appliedRef.current) return;
appliedRef.current = true;
applyUrl(true);
// oxlint-disable-next-line react-hooks/exhaustive-deps
}, []);
useEffect(() => {
if (!appliedRef.current) return;
void setSortParamRef.current(serializeSortState(sortState) || null);
// oxlint-disable-next-line react-hooks/exhaustive-deps
}, [sortState]);
useEffect(() => {
if (!appliedRef.current) return;
void setFilterParamRef.current(serializeFilterState(filterState) || null);
// oxlint-disable-next-line react-hooks/exhaustive-deps
}, [filterState]);
useEffect(() => {
if (!appliedRef.current) return;
void setJoinParamRef.current(serializeJoinOperator(joinOperator) || null);
// oxlint-disable-next-line react-hooks/exhaustive-deps
}, [joinOperator]);
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
if (!appliedRef.current) return;
if (debounceRef.current) clearTimeout(debounceRef.current);
debounceRef.current = setTimeout(() => {
void setSearchParamRef.current(searchText || null);
}, SEARCH_WRITE_DEBOUNCE_MS);
return () => {
if (debounceRef.current) clearTimeout(debounceRef.current);
};
// oxlint-disable-next-line react-hooks/exhaustive-deps
}, [searchText]);
return { applyFromUrl };
}