@void-snippets/react

useTypedSearchParams

Typed search param read/write merged with React Router.

#What it does

useTypedSearchParams bridges your createRouteContract route definitions and React Router's useSearchParams to give you fully typed URL search parameters with merge-semantics on writes. Instead of casting raw strings manually everywhere, you declare the shape once on the route and the hook enforces it at compile time.

#The problem it solves

typescript
// ❌ Without it — error-prone, repetitive, no type safety
const [params] = useSearchParams();
const page = Number(params.get('page') ?? 1); // string → number everywhere you read it
const sort = params.get('sort') as 'asc' | 'desc' | null; // cast, hope for the best

// Writing merges are verbose and fragile
const [, setParams] = useSearchParams();
const newParams = new URLSearchParams(params.toString());
newParams.set('page', String(page + 1));
setParams(newParams); // easy to accidentally drop existing keys
typescript
// ✅ With useTypedSearchParams — one line read, typed write, safe merge
const { search, setSearch } = useTypedSearchParams(AppRoutes.contacts.list);
const page = Number(search.page ?? 1); // TypeScript knows page exists on this route
setSearch({ page: page + 1 });         // merges — other keys are preserved automatically

#Signature

typescript
import { useTypedSearchParams } from '@void-snippets/react';

function useTypedSearchParams<P extends string, S extends Record<string, unknown>>(
  route: ProcessedRoute<P, S>
): {
  search:      Readonly<Partial<S>>;
  setSearch:   (update: Partial<S>) => void;
  clearSearch: () => void;
}

#Parameters

ParameterTypeDescription
routeProcessedRouteA leaf from createRouteContract. The generic S is inferred from the .search() call on the route.

#Return values

FieldTypeDescription
searchReadonly>The current URL search params parsed into the typed shape S. Every key is Partial because any param might be absent from the URL.
setSearch(update)(Partial) => voidMerge update. Only the keys you provide change — every other param in the URL is preserved. Pass undefined for a key to remove it.
clearSearch()() => voidRemove every search param. Equivalent to navigating to the bare path.

Runtime coercion is your responsibility. React Router stores all URL params as strings. Even if you declared page: number in the route's search type, search.page is a string | undefined at runtime. Always coerce where you consume: Number(search.page ?? 1).

#Setup — declare the search shape on the route

typescript
// routes.ts
import { createRouteContract, defineRoute } from '@void-snippets/react';

export const AppRoutes = createRouteContract({
  contacts: {
    list: defineRoute('/contacts').search<{
      page?:  number;           // always coerce to Number() at read site
      limit?: number;
      sort?:  'asc' | 'desc';
      q?:     string;
      status?: 'active' | 'inactive' | 'all';
    }>(),
  },
});

#Full example

tsx
import { useTypedSearchParams } from '@void-snippets/react';
import { usePagination } from '@void-snippets/react';
import { AppRoutes } from '@/routes';
import { contactHooks } from './contacts.hooks';

function ContactsPage() {
  const { search, setSearch, clearSearch } = useTypedSearchParams(
    AppRoutes.contacts.list
  );
  const { queryParams, onPaginationChange, resetPagination } = usePagination(
    Number(search.page ?? 1),
    Number(search.limit ?? 20),
  );

  const { list, pagination, isLoading } = contactHooks.useList({
    page:   Number(search.page  ?? 1),
    limit:  Number(search.limit ?? 20),
    sort:   search.sort,
    q:      search.q,
    status: search.status,
  });

  const handleSearch = (q: string) => {
    // setSearch merges — page resets to 1, everything else preserved
    setSearch({ q: q || undefined, page: 1 });
    resetPagination();
  };

  const hasFilters = !!(search.q || search.sort || search.status);

  return (
    <div>
      <div className="flex items-center gap-3">
        <input
          value={search.q ?? ''}
          onChange={(e) => handleSearch(e.target.value)}
          placeholder="Search contacts…"
        />

        <select
          value={search.sort ?? ''}
          onChange={(e) =>
            setSearch({ sort: (e.target.value as 'asc' | 'desc') || undefined })
          }
        >
          <option value="">Sort: default</option>
          <option value="asc">A → Z</option>
          <option value="desc">Z → A</option>
        </select>

        <select
          value={search.status ?? 'all'}
          onChange={(e) =>
            setSearch({ status: e.target.value as 'active' | 'inactive' | 'all' })
          }
        >
          <option value="all">All statuses</option>
          <option value="active">Active</option>
          <option value="inactive">Inactive</option>
        </select>

        {hasFilters && (
          <button type="button" onClick={clearSearch}>
            Clear all filters
          </button>
        )}
      </div>

      {/* The URL updates on every setSearch call — shareable, bookmarkable */}

      <ContactsTable contacts={list} loading={isLoading} />

      <Pagination
        current={pagination.page}
        pageSize={pagination.limit}
        total={pagination.totalDocuments}
        onChange={(page, limit) => {
          onPaginationChange(page, limit);
          // Sync page/limit back into the URL
          setSearch({ page, limit });
        }}
      />
    </div>
  );
}

#Combining with build() for programmatic navigation

The same route object that powers useTypedSearchParams also exposes build() — so you can construct pre-filtered links with full type safety:

typescript
// Link to contacts filtered by status, starting on page 1
const href = AppRoutes.contacts.list.build({
  search: { status: 'active', sort: 'asc', page: 1 }
});
// → '/contacts?status=active&sort=asc&page=1'

<Link href={href}>View active contacts</Link>

#How the merge works

setSearch reads the current URL, applies your partial update, and navigates. Keys you omit are preserved. Keys you set to undefined are removed.

typescript
// URL: /contacts?q=john&sort=asc&page=3

setSearch({ page: 1 });
// URL becomes: /contacts?q=john&sort=asc&page=1
// q and sort were not touched

setSearch({ sort: undefined });
// URL becomes: /contacts?q=john&page=1
// sort key removed

clearSearch();
// URL becomes: /contacts
// all params removed

#Signature

typescript
function useTypedSearchParams<P extends string, S>(
  route: ProcessedRoute<P, S>
): {
  search:      Readonly<Partial<S>>;
  setSearch:   (update: Partial<S>) => void;
  clearSearch: () => void;
}

#Return values

FieldTypeDescription
searchReadonly>Current search params as a typed object. Partial because any key might be absent.
setSearch(update)(Partial) => voidMerge these keys into the URL. Keys you don't mention are preserved. Pass undefined for a key to remove it.
clearSearch()() => voidRemove all search params.

All URL values are strings at runtime. useSearchParams returns everything as a string even if you declared page: number. Coerce where needed: Number(search.page ?? 1).

#Example

tsx
// Route: .search<{ page: number; sort?: 'asc' | 'desc'; q?: string }>()
function UsersListPage() {
  const { queryParams, onPaginationChange, resetPagination } = usePagination(1, 20);
  const { search, setSearch, clearSearch } = useTypedSearchParams(AppRoutes.dashboard.users.list);

  const page = Number(search.page ?? 1); // coerce string → number

  const { list, pagination, isLoading } = contactHooks.useList({
    page,
    limit:  queryParams.limit,
    sort:   search.sort,
    q:      search.q,
  });

  return (
    <div>
      <input
        value={search.q ?? ''}
        onChange={e => {
          setSearch({ q: e.target.value || undefined, page: 1 });
          resetPagination();
        }}
        placeholder="Search…"
      />

      <select
        value={search.sort ?? ''}
        onChange={e => setSearch({ sort: e.target.value as 'asc' | 'desc' || undefined })}
      >
        <option value="">Default</option>
        <option value="asc">A → Z</option>
        <option value="desc">Z → A</option>
      </select>

      <Button onClick={clearSearch}>Clear filters</Button>

      <ContactsTable contacts={list} loading={isLoading} />

      <Pagination
        current={pagination.page}
        pageSize={pagination.limit}
        total={pagination.totalDocuments}
        onChange={(p, l) => { onPaginationChange(p, l); setSearch({ page: p }); }}
      />
    </div>
  );
}