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
// ❌ 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// ✅ 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
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
| Parameter | Type | Description |
|---|---|---|
route | ProcessedRoute | A leaf from createRouteContract. The generic S is inferred from the .search call on the route. |
#Return values
| Field | Type | Description |
|---|---|---|
search | Readonly | 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 | Merge update. Only the keys you provide change — every other param in the URL is preserved. Pass undefined for a key to remove it. |
clearSearch() | () => void | Remove 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: numberin the route's search type,search.pageis astring | undefinedat runtime. Always coerce where you consume:Number(search.page ?? 1).
#Setup — declare the search shape on the route
// 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
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:
// 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.
// 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
function useTypedSearchParams<P extends string, S>(
route: ProcessedRoute<P, S>
): {
search: Readonly<Partial<S>>;
setSearch: (update: Partial<S>) => void;
clearSearch: () => void;
}#Return values
| Field | Type | Description |
|---|---|---|
search | Readonly | Current search params as a typed object. Partial because any key might be absent. |
setSearch(update) | (Partial | Merge these keys into the URL. Keys you don't mention are preserved. Pass undefined for a key to remove it. |
clearSearch() | () => void | Remove all search params. |
All URL values are strings at runtime.
useSearchParamsreturns everything as a string even if you declaredpage: number. Coerce where needed:Number(search.page ?? 1).
#Example
// 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>
);
}