shadimage
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 · light
nuqs

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 };
}

More from gridcn

Browse all components in this library →