Skip to content

Instantly share code, notes, and snippets.

@huynhducduy
Last active June 4, 2025 23:57
Show Gist options
  • Save huynhducduy/cb259daaf0cb27a0ea8fe422020a09de to your computer and use it in GitHub Desktop.
Save huynhducduy/cb259daaf0cb27a0ea8fe422020a09de to your computer and use it in GitHub Desktop.
Typesafe search & path params hooks for TanStack Router

Typesafe search & path params hooks for TanStack Router

With the following route setup:

import {type} from 'arktype'

const flightDetailSchema = type({
  step: 'number',
  option: 'number.integer',
  applyRewards: 'boolean',
  coupon: 'string',
})

export const Route = createFileRoute('/flights/$flightId/')({
  validateSearch: flightDetailSchema,
})

You will get:

image image
import {type RegisteredRouter, useParams as useBaseParams} from '@tanstack/react-router'
import type {ConstrainLiteral, ResolveUseParams, RouteIds} from '@tanstack/router-core'
import type {NavigateOptionProps} from 'node_modules/@tanstack/router-core/dist/esm/link'
import useSetParam from './useSetParam'
/**
* A React hook that let you use a tanstack router's path param as a useState hook.
*
* @example
* ```tsx
* const [page, setPage] = useParam(Route.id, 'page')
*
* setPage(1)
* await setPage(prev => prev + 1, {
* replace: false,
* resetScroll: true,
* hashScrollIntoView: {
* behavior: 'smooth',
* block: 'start',
* inline: 'nearest',
* },
* })
* ```
*
* @param routeId - The route ID to use
* @param name - The name of the path param to use
* @param defaultOptions - The default navigate options to use
* @returns The value of the path param and a path param async setter.
*/
export default function useParam<
RouteId extends ConstrainLiteral<string, RouteIds<RegisteredRouter['routeTree']>>,
ParamOut extends ResolveUseParams<RegisteredRouter, RouteId, true>,
ParamKey extends keyof ParamOut,
>(routeId: RouteId, name: ParamKey, defaultOptions?: NavigateOptionProps) {
const {[name]: value} = useBaseParams({
from: routeId,
})
return [value as ParamOut[ParamKey], useSetParam(routeId, name, defaultOptions)] as const
}
import {type RegisteredRouter, useSearch as useBaseSearch} from '@tanstack/react-router'
import type {ConstrainLiteral, ResolveUseSearch, RouteIds} from '@tanstack/router-core'
import type {NavigateOptionProps} from 'node_modules/@tanstack/router-core/dist/esm/link'
import useSetSearch from './useSetSearch'
/**
* A React hook that let you use a tanstack router's search param as a useState hook.
*
* @example
* ```tsx
* const [keyword, setKeyword] = useSearch(Route.id, 'keyword')
*
* setKeyword('hello')
* await setKeyword(prev => prev + ' world', {
* replace: false,
* resetScroll: true,
* hashScrollIntoView: {
* behavior: 'smooth',
* block: 'start',
* inline: 'nearest',
* },
* })
* ```
*
* @param routeId - The route ID to use
* @param name - The name of the search param to use
* @param defaultOptions - The default navigate options to use
* @returns The value of the search param and a search param async setter.
*/
export default function useSearch<
RouteId extends ConstrainLiteral<string, RouteIds<RegisteredRouter['routeTree']>>,
SearchParamOut extends ResolveUseSearch<RegisteredRouter, RouteId, true>,
SearchParamKey extends keyof SearchParamOut,
>(routeId: RouteId, name: SearchParamKey, defaultOptions?: NavigateOptionProps) {
const {[name]: value} = useBaseSearch({
from: routeId,
})
return [
value as SearchParamOut[SearchParamKey],
useSetSearch(routeId, name, defaultOptions),
] as const
}
import {type RegisteredRouter} from '@tanstack/react-router'
import type {ConstrainLiteral, MakeRouteMatch, RouteIds} from '@tanstack/router-core'
import type {
NavigateOptionProps,
NavigateOptions,
} from 'node_modules/@tanstack/router-core/dist/esm/link'
import isFunction from '@/utils/types/guards/isFunction'
/**
* A React hook that let you update a tanstack router's path param via a setter.
*
* @example
* ```tsx
* const setPage = useSetParam(Route.id, 'page')
*
* setPage(1)
* await setPage(prev => prev + 1, {
* replace: false,
* resetScroll: true,
* hashScrollIntoView: {
* behavior: 'smooth',
* block: 'start',
* inline: 'nearest',
* },
* })
* ```
*
* @param routeId - The route ID to use
* @param name - The name of the path param to use
* @param defaultOptions - The default navigate options to use
* @returns The path param async setter.
*/
export default function useSetParam<
RouteId extends ConstrainLiteral<string, RouteIds<RegisteredRouter['routeTree']>>,
RouteFullPath extends MakeRouteMatch<RegisteredRouter['routeTree'], RouteId>['fullPath'],
ParamSetter extends Extract<
NavigateOptions<RegisteredRouter, RouteFullPath, RouteFullPath>['params'],
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- match library type
(...args: any) => any
>,
ParamKey extends keyof ReturnType<ParamSetter>,
ParamValueOut extends ReturnType<ParamSetter>[ParamKey],
>(routeId: RouteId, name: ParamKey, defaultOptions?: NavigateOptionProps) {
const navigate = useNavigate()
const latestDefaultOptions = useLatest(defaultOptions)
const {fullPath} = useMatch({from: routeId})
const latestFullPatch = useLatest(fullPath)
return useCallback(
async (
value:
| ParamValueOut
| ((prevValue: Parameters<ParamSetter>[0][ParamKey]) => ParamValueOut),
options?: NavigateOptionProps,
) => {
// @ts-expect-error -- navigate type gone wrong because the typeof fullPath cannot determine at compile time
return navigate({
to: latestFullPatch.current,
search: prevSearch => prevSearch,
params: prevParams => ({
...prevParams,
// @ts-expect-error -- navigate type gone wrong because the typeof fullPath cannot determine at compile time
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument -- navigate type gone wrong because the typeof fullPath cannot determine at compile time
[name]: isFunction(value) ? value(prevParams[name] as ParamValueIn) : value,
}),
replace: true,
resetScroll: false,
...latestDefaultOptions.current,
...options,
})
},
[navigate, name],
)
}
import {type RegisteredRouter} from '@tanstack/react-router'
import type {
ConstrainLiteral,
MakeRouteMatch,
NavigateOptions,
RouteIds,
} from '@tanstack/router-core'
import type {NavigateOptionProps} from 'node_modules/@tanstack/router-core/dist/esm/link'
import isFunction from '@/utils/types/guards/isFunction'
/**
* A React hook that let you update a tanstack router's search param via a setter.
*
* @example
* ```tsx
* const setKeyword = useSetSearch(Route.id, 'keyword')
*
* setKeyword('hello')
* await setKeyword(prev => prev + ' world', {
* replace: false,
* resetScroll: true,
* hashScrollIntoView: {
* behavior: 'smooth',
* block: 'start',
* inline: 'nearest',
* },
* }))
* ```
*
* @param routeId - The route ID to use
* @param name - The name of the search param to use
* @param defaultOptions - The default navigate options to use
* @returns The search param async setter.
*/
export default function useSetSearch<
RouteId extends ConstrainLiteral<string, RouteIds<RegisteredRouter['routeTree']>>,
RouteFullPath extends MakeRouteMatch<RegisteredRouter['routeTree'], RouteId>['fullPath'],
SearchParamSetter extends Extract<
NavigateOptions<RegisteredRouter, RouteFullPath, RouteFullPath>['search'],
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- match library type
(...args: any) => any
>,
SearchParamKey extends keyof ReturnType<SearchParamSetter>,
SearchParamValueOut extends ReturnType<SearchParamSetter>[SearchParamKey],
>(routeId: RouteId, name: SearchParamKey, defaultOptions?: NavigateOptionProps) {
const navigate = useNavigate()
const latestDefaultOptions = useLatest(defaultOptions)
const {fullPath} = useMatch({from: routeId})
const latestFullPatch = useLatest(fullPath)
return useCallback(
async (
value:
| SearchParamValueOut
| ((prevValue: Parameters<SearchParamSetter>[0][SearchParamKey]) => SearchParamValueOut),
options?: NavigateOptionProps,
) => {
// @ts-expect-error -- navigate type gone wrong because the typeof fullPath cannot determine at compile time
return navigate({
to: latestFullPatch.current,
search: prevSearch => ({
...prevSearch,
// @ts-expect-error -- navigate type gone wrong because the typeof fullPath cannot determine at compile time
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument -- navigate type gone wrong because the typeof fullPath cannot determine at compile time
[name]: isFunction(value) ? value(prevSearch[name]) : value,
}),
replace: true,
resetScroll: false,
...latestDefaultOptions.current,
...options,
})
},
[navigate, name],
)
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment