Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
1feda9f
Update decisions-on-dx.md
Sheraff Apr 8, 2024
2749c92
Update route-trees.md
Sheraff Apr 8, 2024
9a6d9a9
Update code-splitting.md
Sheraff Apr 8, 2024
968d54c
feat'react-router): add NotFoundErrorData interface for improved type…
Sheraff Jan 5, 2025
4a52664
revert changes from weird git issue
Sheraff Jan 5, 2025
b0e8dff
Merge branch 'main' into main
Sheraff Jan 5, 2025
1eafefc
more flexible augmentable interface
Sheraff Jan 5, 2025
8f11abd
fix(react-router): update NotFoundRouteProps data type NotFoundRouteC…
Sheraff Jan 5, 2025
92efa37
documentation
Sheraff Jan 11, 2025
5909c47
ci: apply automated fixes
autofix-ci[bot] Jan 11, 2025
659a5df
Merge branch 'main' into main
Sheraff Jan 15, 2025
1e9ed87
useSearchState PoC
Sheraff Jun 30, 2025
b7c92a6
remove unrelated changes, i got lost in all my forks
Sheraff Jun 30, 2025
3a1dbbb
Merge branch 'main' into use-search-state
Sheraff Jul 1, 2025
0c3f843
ci: apply automated fixes
autofix-ci[bot] Jul 1, 2025
c02e148
update
Sheraff Jul 1, 2025
b60fd66
ci: apply automated fixes
autofix-ci[bot] Jul 1, 2025
93bee4b
Merge branch 'TanStack:main' into use-search-state
Sheraff Jul 2, 2025
eeb1d4b
remove initial value
Sheraff Jul 2, 2025
8360775
options
Sheraff Jul 2, 2025
14a15d3
ci: apply automated fixes
autofix-ci[bot] Jul 2, 2025
0852c55
replace unit test
Sheraff Jul 2, 2025
37e6e80
ci: apply automated fixes
autofix-ci[bot] Jul 2, 2025
6ab9ffa
use WeakMap as store instead of keying into router
Sheraff Jul 16, 2025
488007d
ci: apply automated fixes
autofix-ci[bot] Jul 16, 2025
96afb1a
Object.is instead of === to match react useState
Sheraff Jul 16, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions packages/react-router/src/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -317,6 +317,8 @@ export { useNavigate, Navigate } from './useNavigate'
export { useParams } from './useParams'
export { useSearch } from './useSearch'

export { useSearchState } from './useSearchState'

export {
getRouterContext, // SSR
} from './routerContext'
Expand Down
222 changes: 222 additions & 0 deletions packages/react-router/src/useSearchState.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,222 @@
import * as React from 'react'
import { useRouter } from './useRouter'
import { useSearch } from './useSearch'
import type {
AnyRouter,
ConstrainLiteral,
NavigateOptions, // It would be better to use `NavigateOptionProps` instead, but it is not exported from the core package
RegisteredRouter,
ValidateId,
} from '@tanstack/router-core'

type SearchSchema<
TRouter extends AnyRouter,
TFrom extends keyof TRouter['routesById'],
> = keyof TRouter['routesById'][TFrom]['types']['searchSchema']

type AnyKey<TRouter extends AnyRouter, TKey extends string> = ConstrainLiteral<
TKey,
string &
{
[K in keyof TRouter['routesById']]: SearchSchema<TRouter, K>
}[keyof TRouter['routesById']]
>

type FromKey<
TRouter extends AnyRouter,
TFrom extends string,
TKey extends string,
> = ConstrainLiteral<
TKey,
string & SearchSchema<TRouter, TFrom & keyof TRouter['routesById']>
>

type Params<
TRouter extends AnyRouter,
TFrom extends string,
TStrict extends boolean,
TKey extends string,
> =
| {
from?: never
strict: TStrict & false
key: AnyKey<TRouter, TKey>
}
| {
from: ValidateId<TRouter, TFrom>
strict?: TStrict & true
key: FromKey<TRouter, TFrom, TKey>
}

type ValueFrom<
TRouter extends AnyRouter,
TFrom,
TStrict extends boolean,
TKey extends string,
> = TStrict extends false
?
| undefined
| {
[K in keyof TRouter['routesById']]: TKey extends keyof TRouter['routesById'][K]['types']['searchSchema']
? TRouter['routesById'][K]['types']['searchSchema'][TKey]
: never
}[keyof TRouter['routesById']]
: TRouter['routesById'][TFrom &
keyof TRouter['routesById']]['types']['searchSchema'][TKey]

type SetSearchStateOptions = Pick<
NavigateOptions,
| 'hashScrollIntoView'
| 'reloadDocument'
| 'replace'
| 'ignoreBlocker'
| 'resetScroll'
| 'viewTransition'
>

type SetSearchState<TValue> = (
value: TValue | ((prev: TValue) => TValue),
options?: SetSearchStateOptions,
) => void

export function useSearchState<
TRouter extends AnyRouter = RegisteredRouter,
TFrom extends string = string,
TStrict extends boolean = true,
TKey extends string = string,
TValue = ValueFrom<TRouter, TFrom, TStrict, TKey>,
>({
from,
strict,
key,
}: Params<TRouter, TFrom, TStrict, TKey>): readonly [
state: TValue,
setState: SetSearchState<TValue>,
] {
// state
const state = useSearch(
React.useMemo(
() => ({
from,
select: getter.bind(null, key),
strict,
}),
[key, from, strict],
),
) as TValue

// setState
const router = useRouter()
const setState = React.useMemo(
() => (setter<TValue>).bind(null, router, key),
[router, key],
)

return [state, setState]
}

const getter = (key: string, state: object) =>
// @ts-expect-error -- no need to strictly type this internal function
state[key]

/**
* We temporarily store the search state so that multiple calls
* to setState in the same tick will accumulate changes instead
* of overwriting them.
*
* We use a WeakMap to support potentially multiple routers
* in the same app, and to avoid memory leaks.
*/
const routerStore = new WeakMap<
AnyRouter,
{
search: object | null
opts: SetSearchStateOptions | null
scheduled: boolean | null
}
>()

function setter<T>(
router: AnyRouter,
key: string,
value: T | ((prev: T) => T),
options?: SetSearchStateOptions,
) {
let store = routerStore.get(router)
const prev = store?.search || router.state.location.search
const next = {
...prev,
[key]:
typeof value === 'function'
? // @ts-expect-error -- no need to strictly type this internal function
value(prev[key])
: value,
}

// this `setState` did not change the search state,
// so we don't need to navigate
if (Object.is(next[key], prev[key])) return

// create a store for this router if it's the first time
if (!store) {
store = {
search: null,
opts: null,
scheduled: null,
}
routerStore.set(router, store)
}

// accumulate changes in the store
store.search = next
if (options) {
const current = store.opts
if (current) {
current.hashScrollIntoView ||= options.hashScrollIntoView
current.reloadDocument ||= options.reloadDocument
current.replace = current.replace === false ? false : options.replace
current.ignoreBlocker ||= options.ignoreBlocker
current.resetScroll ||= options.resetScroll
current.viewTransition ||= options.viewTransition
} else {
store.opts = options
}
}

// a microtask is already scheduled in this tick, nothing else to do
if (store.scheduled) return
store.scheduled = true

// if a call to `navigate()` happens in the same tick as this setter,
// cancel the microtask and let the `navigate()` call handle the state update
const clear = router.subscribe(
'onBeforeNavigate',
() => (store.scheduled = false),
)

// we use a microtask to allow for multiple synchronous calls to `setState`
// to accumulate changes but still call `navigate()` only once
queueMicrotask(() => {
clear()
if (!store.scheduled) return

const options = store.opts

void router.navigate({
...store.opts,
hash: router.state.location.hash,
search: store.search,
to: router.state.location.pathname,
replace: options?.replace !== false,
})

/**
* After a tick, we nullify the property to avoid memory leaks,
* and to ensure that the next setState call will start from
* the current search state.
*/
store.search = null
store.opts = null
store.scheduled = null
})
}
Loading
Loading