T
TanStack•4mo ago
extended-salmon

Hooks proposal

I have written 2 hooks that we've been finding pretty useful at work, I was wondering whether they might be of interest to the community. They're fully type-safe like native tanstack/router hooks (except for route masking because we don't use that, but that's seems doable if needed). The first is useNavigatePreload, it works like useNavigate, but in 2 steps, first to preload, then to actually navigate.
const preload = useNavigatePreload()
const { mutate } = useMutation({ ... })
const onSubmit = (data) => {
const navigate = preload('/foo/$id')
mutate(data, {
onSuccess: () => navigate({ id: 123 })
})
}
const preload = useNavigatePreload()
const { mutate } = useMutation({ ... })
const onSubmit = (data) => {
const navigate = preload('/foo/$id')
mutate(data, {
onSuccess: () => navigate({ id: 123 })
})
}
Here, we need to navigate programmatically because it needs to happen after a "code event" (a mutation here), and not a "user action" (where we would use a regular link). But we still want some preloading. So the preload() call will do a router.loadRouteChunk under the hood (not actually call the loaders, because the mutation is likely to change things in the destination route). Having navigate tied to preload ensures we preload the correct route. The second is useSearchState. It's based on useSearch but behaves like a useState.
const [value, setValue] = useSearchState({
from: '/foo',
key: 'bar',
})
return <button onClick={() => setValue(v => v + 1)} />
const [value, setValue] = useSearchState({
from: '/foo',
key: 'bar',
})
return <button onClick={() => setValue(v => v + 1)} />
This hook makes it very easy to manipulate search params in a way that is familiar to react devs. It also batches calls to the setState into a single navigate call to minimize work by the router. I can provide the actual implementation if someone wants either of those. Or maybe seeing the idea is helpful enough to someone.
31 Replies
dependent-tan
dependent-tan•4mo ago
about useNavigatePreload, why do you need this? navigate would also cause the route chunk to be loaded if not done so yet useSearchState might be interesting, yes. i'll paste a version that @TkDodo 🔮 once wrote
extended-salmon
extended-salmonOP•4mo ago
that's because of situations like in the example: we know where we want to navigate before we actually want to navigate. So that gives us an opportunity to preload the route
dependent-tan
dependent-tan•4mo ago
export const useSearchState = <
TRouteTree extends AnyRoute = RegisteredRouter['routeTree'],
TFrom extends string | undefined = undefined,
TConstrainedFrom = Constrain<TFrom, RouteIds<TRouteTree>>,
TSearch = Expand<RouteById<TRouteTree, TConstrainedFrom>['types']['fullSearchSchema']>,
TKey extends keyof TSearch = never,
TSearchSlice = TSearch[TKey],
TSelected = TSearchSlice,
>(options: {
from: StringLiteral<TConstrainedFrom>
key: TKey
select?: (search: TSearchSlice) => TSelected
}) => {
const key = options.key as keyof TSearch

const previousResult = useRef<TSelected>()

const state = useSearch({
from: options.from,
select: (search: TSearch) => {
const slice = search[key]

if (options.select) {
const newSlice = replaceEqualDeep(previousResult.current, options.select(search[key] as TSearchSlice))

previousResult.current = newSlice

return newSlice
}

return slice
},
})
const navigate = useNavigate()

const setState = useCallback(
(value: Updater<TSearchSlice>) => {
void navigate({
from: options.from,
replace: true,
search: ((previousSearch: TSearch) => {
const previousSlice = previousSearch[key]

return {
...previousSearch,
[key]: {
...previousSlice,
...functionalUpdate(value, previousSlice as TSearchSlice),
},
}
}) as never,
})
},
[key, navigate, options.from],
)

return [state as TSelected, setState] as const
}
export const useSearchState = <
TRouteTree extends AnyRoute = RegisteredRouter['routeTree'],
TFrom extends string | undefined = undefined,
TConstrainedFrom = Constrain<TFrom, RouteIds<TRouteTree>>,
TSearch = Expand<RouteById<TRouteTree, TConstrainedFrom>['types']['fullSearchSchema']>,
TKey extends keyof TSearch = never,
TSearchSlice = TSearch[TKey],
TSelected = TSearchSlice,
>(options: {
from: StringLiteral<TConstrainedFrom>
key: TKey
select?: (search: TSearchSlice) => TSelected
}) => {
const key = options.key as keyof TSearch

const previousResult = useRef<TSelected>()

const state = useSearch({
from: options.from,
select: (search: TSearch) => {
const slice = search[key]

if (options.select) {
const newSlice = replaceEqualDeep(previousResult.current, options.select(search[key] as TSearchSlice))

previousResult.current = newSlice

return newSlice
}

return slice
},
})
const navigate = useNavigate()

const setState = useCallback(
(value: Updater<TSearchSlice>) => {
void navigate({
from: options.from,
replace: true,
search: ((previousSearch: TSearch) => {
const previousSlice = previousSearch[key]

return {
...previousSearch,
[key]: {
...previousSlice,
...functionalUpdate(value, previousSlice as TSearchSlice),
},
}
}) as never,
})
},
[key, navigate, options.from],
)

return [state as TSelected, setState] as const
}
yeah but still, what does this actually do mean in terms of UX/UI? it just means you dont trigger the pendingComponent since you are preloading but it will not be faster
extended-salmon
extended-salmonOP•4mo ago
it just means we're loading the JS chunks while the mutation is ongoing. Yeah it's not much, but it's so easy to use that we might as well.
dependent-tan
dependent-tan•4mo ago
ah i see, you parallelize route chunk loading with mutation
extended-salmon
extended-salmonOP•4mo ago
exactly
dependent-tan
dependent-tan•4mo ago
maybe a bit too niche for a core hook we should do it shadcn style, copy the hook in your project if you want it whereas the useSearchState is more likely to be used i think but still, there might be different tastes this is why we held off with those higher level hooks for now
extended-salmon
extended-salmonOP•4mo ago
TkDodo's useSearchState is simpler than mine! I might have gotten a little lost in the types, but I also have batching which I think is pretty nice for a hook like this (since a useState would also batch subsequent calls)
extended-salmon
extended-salmonOP•4mo ago
yeah I definitely get it, I just though I might add my grain of salt to the conversation
dependent-tan
dependent-tan•4mo ago
absolutely appreciated thats why I think a different distribution mechanism than putting it in a library is probably best here
extended-salmon
extended-salmonOP•4mo ago
ah ah I don't like the shadcn style distribution, I always get FOMO that I don't have the latest version of whatever stuff I copied. But ppl seem to enjoy it.
dependent-tan
dependent-tan•4mo ago
lol but you already see there are many ways to rome here for e.g. useSearchState
extended-salmon
extended-salmonOP•4mo ago
yeah but mine's better (i'm just trolling) thanks for the chat
dependent-tan
dependent-tan•4mo ago
i would really like some user contributed high level hook collection somewhere (outside of discord)
extended-salmon
extended-salmonOP•4mo ago
since I wrote those hooks, I think they broke at least 2 times on minor releases of tanstack/router, maybe when the lib reaches a slightly more stable state then it might be more reasonnable to have ppl copy/paste some high level hooks
dependent-tan
dependent-tan•4mo ago
how did they break? types? or runtime
extended-salmon
extended-salmonOP•4mo ago
yeah types only
dependent-tan
dependent-tan•4mo ago
ok, thats kinda expected when reaching deep into the router types no guarantuees there
extended-salmon
extended-salmonOP•4mo ago
I'm not blaming the release strategy, I'm ok w/ it, but if we have this kind of hook copy/pasted in many codebases, we can't "remote fix" them
dependent-tan
dependent-tan•3mo ago
sure slippery slope coming back to this why do we need the "batching" part?
extended-salmon
extended-salmonOP•3mo ago
Sorry I never saw those last messages. The batching is so it behaves like a react useState:
const onClick = () => {
setFoo(1)
setBar(2)
}
const onClick = () => {
setFoo(1)
setBar(2)
}
In this example, if setFoo and setBar come from useState, this will only cause a single render. In this same vein, if they come from useSearchState, they should only cause 1 navigation.
dependent-tan
dependent-tan•3mo ago
what happens without the batching and multiple set calls from useSearchState ? multiple renders? we should get this into the main repo
vicious-gold
vicious-gold•3mo ago
Hi! Super cool topic, was looking for the same thing. I was tired of writing the same setter with (prev) => {...prev, etc.} all the time. So +1 on having this in the lib in any way 🙂 In the meantime, I was wondering: - Would you be able to share a version of the pasted useSearchState that compiles, with all imports & all (the one from tkDodo ?) - On bostonsheraff's version, for my information, how come you need the binding and all? From your version I was able to get it to work with only this:
export function useSearchState<
TRouter extends AnyRouter = RegisteredRouter,
TFrom extends string = string,
TKey extends string = string,
TValue = ValueFrom<TRouter, TFrom, TKey>,
>(
from: ValidateId<TRouter, TFrom>,
key: FromKey<TRouter, TFrom, TKey>
): readonly [
state: TValue,
setState: (value: TValue | ((prev: TValue) => TValue), push_state?: boolean) => void,
] {
const search = useSearch({ from });
const navigate = useNavigate({ from });

return [
search[key],
(v: ValueFrom<TRouter, TFrom, TKey>) =>
navigate({ search: (prev: any) => ({ ...prev, [key]: v }) }),
];
}
export function useSearchState<
TRouter extends AnyRouter = RegisteredRouter,
TFrom extends string = string,
TKey extends string = string,
TValue = ValueFrom<TRouter, TFrom, TKey>,
>(
from: ValidateId<TRouter, TFrom>,
key: FromKey<TRouter, TFrom, TKey>
): readonly [
state: TValue,
setState: (value: TValue | ((prev: TValue) => TValue), push_state?: boolean) => void,
] {
const search = useSearch({ from });
const navigate = useNavigate({ from });

return [
search[key],
(v: ValueFrom<TRouter, TFrom, TKey>) =>
navigate({ search: (prev: any) => ({ ...prev, [key]: v }) }),
];
}
And adding my two cents, here is a helper returning a function to get the setter from the key, using currying:
export function useGetSearchSetterFor<
TRouter extends AnyRouter = RegisteredRouter,
TFrom extends string = string,
>(
from: ValidateId<TRouter, TFrom>
): <TKey extends string = string>(
key: FromKey<TRouter, TFrom, TKey>
) => (v: ValueFrom<TRouter, TFrom, TKey>) => void {
const navigate = useNavigate({ from });

return <TKey extends string>(key: FromKey<TRouter, TFrom, TKey>) =>
(v: ValueFrom<TRouter, TFrom, TKey>) =>
navigate({ search: (prev: any) => ({ ...prev, [key]: v || undefined }) });
}
export function useGetSearchSetterFor<
TRouter extends AnyRouter = RegisteredRouter,
TFrom extends string = string,
>(
from: ValidateId<TRouter, TFrom>
): <TKey extends string = string>(
key: FromKey<TRouter, TFrom, TKey>
) => (v: ValueFrom<TRouter, TFrom, TKey>) => void {
const navigate = useNavigate({ from });

return <TKey extends string>(key: FromKey<TRouter, TFrom, TKey>) =>
(v: ValueFrom<TRouter, TFrom, TKey>) =>
navigate({ search: (prev: any) => ({ ...prev, [key]: v || undefined }) });
}
Any comments or suggestions welcomed!
extended-salmon
extended-salmonOP•3mo ago
I'm using bind to avoid recreating function scopes on every re-render. This is a minor optimisation, but since this kind of hook usually goes in components that re-render pretty often, it felt like a free win I don't remember well, I should test it to give an answer. But at least 2 navigations (2 history replaces, or if you push, 2 actual history entries), I don't remember if it does re-render twice or not.
vicious-gold
vicious-gold•3mo ago
Hi ! Adding my 2 cents again because I've been playing with the matter recently and found the topic to be fun: I've come up with a new helper that generates the various (independent) setters for the search params, from the route, and properly typed. It allows to consume them like this:
const { query, sort, page } = myRoute.useSearch();
const { setQuery, setSort, setPage } = useSetSearch(myRoute);
const { query, sort, page } = myRoute.useSearch();
const { setQuery, setSort, setPage } = useSetSearch(myRoute);
This way I can stay close to the way the framework is expressed but just have a helper for all these navigates. The implementation is a bit clunky because I had to access the validateSearch object to reconstruct the setters from the keys, and they're not typed, but I'm sure it could be improved. I'd be very happy to have your opinion on this, suggestions to improve it, etc ! Thanks a lot
import { type AnyRouter, RegisteredRouter } from '@tanstack/react-router';
import {
AnyContext,
AnyRoute,
ResolveFullPath,
ResolveId,
ResolveParams,
Route,
UseSearchResult,
} from '@tanstack/router-core';
import { ZodObject } from 'zod';

const capitalize = <T extends string>(str: T): Capitalize<T> =>
(str.charAt(0).toUpperCase() + str.slice(1)) as Capitalize<T>;

const toSetterKey = <T extends string>(key: T): `set${Capitalize<T>}` => `set${capitalize(key)}`;

type SettersFor<T> = {
[key in keyof Required<T> as `set${Capitalize<string & key>}`]: (value: T[key]) => void;
};
import { type AnyRouter, RegisteredRouter } from '@tanstack/react-router';
import {
AnyContext,
AnyRoute,
ResolveFullPath,
ResolveId,
ResolveParams,
Route,
UseSearchResult,
} from '@tanstack/router-core';
import { ZodObject } from 'zod';

const capitalize = <T extends string>(str: T): Capitalize<T> =>
(str.charAt(0).toUpperCase() + str.slice(1)) as Capitalize<T>;

const toSetterKey = <T extends string>(key: T): `set${Capitalize<T>}` => `set${capitalize(key)}`;

type SettersFor<T> = {
[key in keyof Required<T> as `set${Capitalize<string & key>}`]: (value: T[key]) => void;
};
export function useSetSearch<
TRouter extends AnyRouter = RegisteredRouter,
TParentRoute extends AnyRoute = AnyRoute,
TPath extends string = '/',
TFullPath extends string = ResolveFullPath<TParentRoute, TPath>,
TCustomId extends string = string,
TId extends string = ResolveId<TParentRoute, TCustomId, TPath>,
TSearchValidator = undefined,
TParams = ResolveParams<TPath>,
TRouterContext = AnyContext,
TRouteContextFn = AnyContext,
TBeforeLoadFn = AnyContext,
TLoaderDeps extends Record<string, any> = {},
TLoaderFn = undefined,
TChildren = unknown,
TFileRouteTypes = unknown,
>(
route: Route<
TParentRoute,
TPath,
TFullPath,
TCustomId,
TId,
TSearchValidator,
TParams,
TRouterContext,
TRouteContextFn,
TBeforeLoadFn,
TLoaderDeps,
TLoaderFn,
TChildren,
TFileRouteTypes
>
): SettersFor<UseSearchResult<TRouter, TId, true, unknown>> {
// the validateSearch is indeed a zodObject in practice as we use zod to validate the search
// the type parameter is irrelevant as we want to access the keys and here lose the type anyway
const validateSearch = route.options.validateSearch as ZodObject<Record<string, any>>;
const keys = Object.keys(validateSearch?.shape);

const nav = route.useNavigate();

return keys.reduce(
(acc, key) => {
// @ts-ignore. reason: the type of the key is lost in the Object.keys. the typing of value & prev are irrelevant because typed by overall function
acc[toSetterKey(key)] = (value) => nav({ search: (prev) => ({ ...prev, [key]: value }) });
return acc;
},
{} as SettersFor<UseSearchResult<TRouter, TId, true, unknown>>
);
}
export function useSetSearch<
TRouter extends AnyRouter = RegisteredRouter,
TParentRoute extends AnyRoute = AnyRoute,
TPath extends string = '/',
TFullPath extends string = ResolveFullPath<TParentRoute, TPath>,
TCustomId extends string = string,
TId extends string = ResolveId<TParentRoute, TCustomId, TPath>,
TSearchValidator = undefined,
TParams = ResolveParams<TPath>,
TRouterContext = AnyContext,
TRouteContextFn = AnyContext,
TBeforeLoadFn = AnyContext,
TLoaderDeps extends Record<string, any> = {},
TLoaderFn = undefined,
TChildren = unknown,
TFileRouteTypes = unknown,
>(
route: Route<
TParentRoute,
TPath,
TFullPath,
TCustomId,
TId,
TSearchValidator,
TParams,
TRouterContext,
TRouteContextFn,
TBeforeLoadFn,
TLoaderDeps,
TLoaderFn,
TChildren,
TFileRouteTypes
>
): SettersFor<UseSearchResult<TRouter, TId, true, unknown>> {
// the validateSearch is indeed a zodObject in practice as we use zod to validate the search
// the type parameter is irrelevant as we want to access the keys and here lose the type anyway
const validateSearch = route.options.validateSearch as ZodObject<Record<string, any>>;
const keys = Object.keys(validateSearch?.shape);

const nav = route.useNavigate();

return keys.reduce(
(acc, key) => {
// @ts-ignore. reason: the type of the key is lost in the Object.keys. the typing of value & prev are irrelevant because typed by overall function
acc[toSetterKey(key)] = (value) => nav({ search: (prev) => ({ ...prev, [key]: value }) });
return acc;
},
{} as SettersFor<UseSearchResult<TRouter, TId, true, unknown>>
);
}
extended-salmon
extended-salmonOP•3mo ago
I don't think this can work for every validateSearch, since it can be many things other than zod. Maybe with a proxy it could be made more generic. This is interesting though.
vicious-gold
vicious-gold•3mo ago
indeed, that's definitely one of the shortcoming of such implementation, which works for me in practice but isn't general enough. But I guess knowing the innards of tanstack-router it should be possible to write it in a cleaner way.
extended-salmon
extended-salmonOP•3mo ago
Here's a first draft for a useSearchState hook: https://github.com/TanStack/router/pull/4552 Sorry for the delay, I was playing Clair Obscur Expedition 33
GitHub
feat(react-router): useSearchState by Sheraff · Pull Request #4552...
This is a 1st draft proposal for useSearchState: a hook designed to be used like a react useState, but manipulates route search params instead. Example usage: export const Route = createFileRoute(&...
extended-salmon
extended-salmonOP•3mo ago
The implementation is actually better than what I initially proposed in this thread, because this PR made me write unit tests!
dependent-tan
dependent-tan•3mo ago
cc @TkDodo 🔮
extended-salmon
extended-salmonOP•3mo ago
also, please don't do a commit-by-commit review, because I mistakenly forked an old fork of the repo... The final diff on github is correct though, no worries just FYI, i updated our implementation of useSearchState at work to use the same one as in this PR. If there's something fundamentally wrong with this, we'll know!

Did you find this page helpful?