T
TanStack5w ago
exotic-emerald

Router + Query integration // route invalidation

I'm torn between 2 things:
loaderDeps = ({ search }) => search,
loader: ({ context: { queryClient }, deps, params }) =>
queryClient.ensureQueryData({
queryKey: [params, deps],
queryFn: () => fetch(`/api/${params}?${deps}`) // pardon me here, i'm lazy to code all
})
loaderDeps = ({ search }) => search,
loader: ({ context: { queryClient }, deps, params }) =>
queryClient.ensureQueryData({
queryKey: [params, deps],
queryFn: () => fetch(`/api/${params}?${deps}`) // pardon me here, i'm lazy to code all
})
then either:
function Component() {
const data = Route.useLoaderData()
}
function Component() {
const data = Route.useLoaderData()
}
which is extremely simple, does not need any loading management of any kind but needs double cache invalidation:
await router.invalidate({ filter: ({ routePath }) => routePath = '/xxx' })
await queryClient.invalidateQueries({ queryKey: ['/xxx'] })
await router.invalidate({ filter: ({ routePath }) => routePath = '/xxx' })
await queryClient.invalidateQueries({ queryKey: ['/xxx'] })
or:
function Component() {
const params = Route.useParams()
const search = Route.useSearch()

const { data } = useSuspenseQuery({
queryKey: [params, deps],
queryFn: () => fetch(`/api/${params}?${deps}`) // pardon me here, i'm lazy to code all
})
}
function Component() {
const params = Route.useParams()
const search = Route.useSearch()

const { data } = useSuspenseQuery({
queryKey: [params, deps],
queryFn: () => fetch(`/api/${params}?${deps}`) // pardon me here, i'm lazy to code all
})
}
where there's no double cache invalidation needed, but a double api declaration (the query options in both loader and useSuspenseQuery) what i could see as a workaround is:
function createQuery(params: any, search: any) {
return {
queryKey: [],
queryFn: async () => {}
}
}

// then
loader: ({ context: { queryClient }, deps, params }) =>
queryClient.ensureQueryData(createQuery(params, deps))

// and
function Component() {
const params = Route.useParams()
const search = Route.useSearch()

const { data } = useSuspenseQuery(createQuery(params, search))
}
function createQuery(params: any, search: any) {
return {
queryKey: [],
queryFn: async () => {}
}
}

// then
loader: ({ context: { queryClient }, deps, params }) =>
queryClient.ensureQueryData(createQuery(params, deps))

// and
function Component() {
const params = Route.useParams()
const search = Route.useSearch()

const { data } = useSuspenseQuery(createQuery(params, search))
}
but i don't know how to type params and search. I have tried ReturnType<(typeof Route)['useParams']> but it returns any 😄 ...and it's still not as appealing DX as the very 1st one, which only have an issue of route caching data on top of react-query caching data (and subscriptions) please advice 🙏
28 Replies
exotic-emerald
exotic-emeraldOP5w ago
(mentioning @TkDodo 🔮 only because you told me to join discord on X)e btw I understand i may be missing on subscriptions since we don't use useQuery/useSuspenseQuery therefore queryClient.setQueryData() would not trigger a re-render. I'm also considering this in the balance.
correct-apricot
correct-apricot5w ago
I think the best approach is to not useLoaderData if you use query, otherwise, what do you have it for? You could just as well call fetch() in the loader without it as caching doesn't really do anything. okay, retries and stuff, but still. If you want loader data, don't use ensureQueryData because it never refetches - just do queryClient.fetchQuery and maybe pass a staleTime: 0 explicitly to always force a fetch when the loader runs. Then, you can get rid of queryClient.invalidateQueries because invalidating the router will run the loader, which will always execute the query then. However, not having a subscription with useQuery or useSuspenseQuery makes the query eligible for garbage collection, and invalidateQueries won't refetch it because the query is not active. So I'm not even sure how the double invalidation would get you fresh data because router invalidation runs the loader, but ensureQueryData only returns what is in the cache already and invalidateQueries doesn't refetch either 🤔 So, really the best way would be just loader + useSuspenseQuery, then just do normal invalidation. That's not a workaround, imo it's the best possible usage. The drawback is you "duplicate" things between loader and component, but there's actually a nice way around this with router context. I'm not supposed to talk about this because the API is undocumented and will change, but the concept is:
loaderDeps = ({ search }) => search,
context: ({ params, deps }) => ({
myQuery: queryOptions({
queryKey: [params, deps],
queryFn: () => fetch(`/api/${params}?${deps}`)
})
}),
loader: async ({ context }) => {
await context.queryClient.ensureQueryData(context.myQuery)
},
loaderDeps = ({ search }) => search,
context: ({ params, deps }) => ({
myQuery: queryOptions({
queryKey: [params, deps],
queryFn: () => fetch(`/api/${params}?${deps}`)
})
}),
loader: async ({ context }) => {
await context.queryClient.ensureQueryData(context.myQuery)
},
then in the component:
function Component() {
const { myQuery } = Route.useRouteContext()
const { data } = useSuspenseQuery(myQuery) // or useQuery
}
function Component() {
const { myQuery } = Route.useRouteContext()
const { data } = useSuspenseQuery(myQuery) // or useQuery
}
correct-apricot
correct-apricot5w ago
GitHub
react-query-beyond-the-basics/src/routes/index.tsx at a77f3d1f1f668...
Contribute to TkDodo/react-query-beyond-the-basics development by creating an account on GitHub.
correct-apricot
correct-apricot5w ago
Again, the context feature is currently undocumented because we want to rename it, so use with caution. You could use beforeLoad too but it's not as optimized for this use-case
correct-apricot
correct-apricot5w ago
just ship the rename 😂
robust-apricot
robust-apricot5w ago
lol just implement the whole concept!
correct-apricot
correct-apricot5w ago
the concept already works like it should for router is my point. There's nothing missing afaik, it's "only" for start
exotic-emerald
exotic-emeraldOP5w ago
@TkDodo 🔮 understood. it's indeed a good idea to use the context to build the query, it'd solve the typing issue i've a follow up question. the router loader is also nice because you can compose multiple sources, but that can't be done with a single suspense query (and afaik we should not chain suspense queries right?) you can imagine 2 fetches in parallel, let's say "user profile by id" and "user memberships by id" i have found that i could not rely on context(), but beforeLoad() works ok. i have a routes such as: /$orgSlug/route.tsx /$orgSlug/locations/$locationId.tsx 1st one does a BE check to fetch the organization by slug and returns { organizationId } ; i went with beforeLoad but i'm not sure if it should be loader. it can't be context because it's using async calls to BE then if using loaders, i need to work for building the queryOptions and fetching data in sub route loaders, so it sucks (imho). so i went with beforeLoad. /$orgSlug/route.tsx
{
beforeLoad: ({ context: { qc }, params: { orgSlug } }) =>
qc.ensureQueryData(trpc.org.getOrgIdBySlug({ orgSlug }),
loader: ({ context: { qc } }) =>
qc.ensureQueryData(trpc.me.getDashboard.queryOptions()),
}
{
beforeLoad: ({ context: { qc }, params: { orgSlug } }) =>
qc.ensureQueryData(trpc.org.getOrgIdBySlug({ orgSlug }),
loader: ({ context: { qc } }) =>
qc.ensureQueryData(trpc.me.getDashboard.queryOptions()),
}
/$orgSlug/locations/$locationId.tsx
{
beforeLoad: ({ context: { qc, organizationId }, params: { locationId } }) => ({
queryOptions: trpc.location.getLocation({ locationId, organizationId }),
}),
loader: async ({ context: { queryOptions } }) => {
await qc.ensureQueryData(queryOptions)
},
}
{
beforeLoad: ({ context: { qc, organizationId }, params: { locationId } }) => ({
queryOptions: trpc.location.getLocation({ locationId, organizationId }),
}),
loader: async ({ context: { queryOptions } }) => {
await qc.ensureQueryData(queryOptions)
},
}
this seems to work ok in terms of cascading (on this, the API is a bit quirky: context and loader get deps, but beforeLoad gets search - i feel like it's related to the loader's cache key, but it feels odd) also using trpc, there are type issues in passing the queryOptions to the context :/ (had to declaration: false it)
Exported variable 'Route' has or is using name 'UnusedSkipTokenTRPCQueryOptionsOut' from external module "~/node_modules/@trpc/tanstack-react-query/dist/index" but cannot be named
Exported variable 'Route' has or is using name 'UnusedSkipTokenTRPCQueryOptionsOut' from external module "~/node_modules/@trpc/tanstack-react-query/dist/index" but cannot be named
i've moved further, and the pattern also gets nastier when you want conditional loading. with loader, it's so easy:
loader: ({ context: { queryClient }, deps: { userId } }) => {
return userId
? queryClient.ensureQueryData(trpc.users.getUserById.queryOptions({ userId }))
: undefined
},
validateSearch: zodValidator(z.object({ userId: z.string().optional() })),
loader: ({ context: { queryClient }, deps: { userId } }) => {
return userId
? queryClient.ensureQueryData(trpc.users.getUserById.queryOptions({ userId }))
: undefined
},
validateSearch: zodValidator(z.object({ userId: z.string().optional() })),
so that we can do "pre-filled" forms if/when param exists in searchParams
function RouteComponent() {
const maybeUser = Route.useLoaderData()
const [user, setUser] = useState(maybeUser)
}
function RouteComponent() {
const maybeUser = Route.useLoaderData()
const [user, setUser] = useState(maybeUser)
}
with RQ i first thought to do:
beforeLoad: ({ context: { queryClient }, search }) => ({
queryOptions: search.userId
? trpc.users.getUserById.queryOptions({ userId }),
: undefined
}),
loader: ({ context: { queryClient, queryOptions } }) => {
if (queryOptions) {
? await queryClient.ensureQueryData(queryOptions)
: undefined
},
validateSearch: zodValidator(z.object({ userId: z.string().optional() })),
beforeLoad: ({ context: { queryClient }, search }) => ({
queryOptions: search.userId
? trpc.users.getUserById.queryOptions({ userId }),
: undefined
}),
loader: ({ context: { queryClient, queryOptions } }) => {
if (queryOptions) {
? await queryClient.ensureQueryData(queryOptions)
: undefined
},
validateSearch: zodValidator(z.object({ userId: z.string().optional() })),
but if queryOptions is undefined, i can't do:
function RouteComponent() {
const { queryOptions } = Route.useRouteContext()
// v can't pass undefined to useQuery
const { data: user } = useQuery(queryOptions, { enabled: !!queryOptions })

const [user, setUser] = useState(maybeUser)
useEffect(() => setUser(maybeUser), [maybeUser])
}
function RouteComponent() {
const { queryOptions } = Route.useRouteContext()
// v can't pass undefined to useQuery
const { data: user } = useQuery(queryOptions, { enabled: !!queryOptions })

const [user, setUser] = useState(maybeUser)
useEffect(() => setUser(maybeUser), [maybeUser])
}
so i have to:
beforeLoad: ({ context: { queryClient }, search }) => ({
queryOptions:
trpc.users.getUserById.queryOptions({ userId: search.userId ?? '' }),
}),
loaderDeps: ({ search }) => search,
loader: ({ context: { queryClient, queryOptions }, deps }) => {
if (deps.userId) {
? await queryClient.ensureQueryData(queryOptions)
: undefined
},
validateSearch: zodValidator(z.object({ userId: z.string().optional() })),
beforeLoad: ({ context: { queryClient }, search }) => ({
queryOptions:
trpc.users.getUserById.queryOptions({ userId: search.userId ?? '' }),
}),
loaderDeps: ({ search }) => search,
loader: ({ context: { queryClient, queryOptions }, deps }) => {
if (deps.userId) {
? await queryClient.ensureQueryData(queryOptions)
: undefined
},
validateSearch: zodValidator(z.object({ userId: z.string().optional() })),
and
function RouteComponent() {
const { queryOptions } = Route.useRouteContext()
const search = Route.useSearch()

const { data: maybeUser } = useQuery(queryOptions, { enabled: !!search.userId })

const [user, setUser] = useState(maybeUser)
useEffect(() => setUser(maybeUser), [maybeUser])
}
function RouteComponent() {
const { queryOptions } = Route.useRouteContext()
const search = Route.useSearch()

const { data: maybeUser } = useQuery(queryOptions, { enabled: !!search.userId })

const [user, setUser] = useState(maybeUser)
useEffect(() => setUser(maybeUser), [maybeUser])
}
which doesn't seem like a good DX at all (more verbose + useEffect). i'm sure i'm missing something @TkDodo 🔮 would you have an example of such in your workshop maybe 🙏 ?
correct-apricot
correct-apricot5w ago
i have found that i could not rely on context(), but beforeLoad() works ok.
why not? I would just use a skipToken for that. Raw RQ:
beforeLoad: ({ context: { queryClient }, search }) => ({
queryOptions: queryOptions({
queryKey: ['user', search.userId],
queryFn: search.userId ? () => fetchUser(search.userId) : skipToken
})
}),
beforeLoad: ({ context: { queryClient }, search }) => ({
queryOptions: queryOptions({
queryKey: ['user', search.userId],
queryFn: search.userId ? () => fetchUser(search.userId) : skipToken
})
}),
then just consume as usual. I'm sure trpc has a way to pass skipToken
exotic-emerald
exotic-emeraldOP5w ago
because i need to load things to expose them in context, but context is not async there's no elegant way to do this when using trpc or RQ wrappers. i'd need to write:
beforeLoad: () => {
const { queryOptions: { queryKey, queryFn } } = trpc...blah...queryOptions(params)

return { queryOptions: { queryKey, queryFn: ternary }
}
beforeLoad: () => {
const { queryOptions: { queryKey, queryFn } } = trpc...blah...queryOptions(params)

return { queryOptions: { queryKey, queryFn: ternary }
}
i guess all the types would be inferred from the queryFn return type so i would have to check that queryoptions.queryFn isn't undefined to get proper typing in component
correct-apricot
correct-apricot5w ago
yes context is synchronous but the idea is to just put queryOptions in there which is also sync ? There's nothing async in the exmples you've shown so far...
correct-apricot
correct-apricot5w ago
you can pass a skipToken instead of params: https://trpc.io/docs/client/react/disabling-queries
Disabling Queries | tRPC
To disable queries, you can pass skipToken as the first argument to useQuery or useInfiniteQuery. This will prevent the query from being executed.
correct-apricot
correct-apricot5w ago
so it would be:
context: ({ context: { queryClient }, search }) => ({
queryOptions: trpc.users.getUserById.queryOptions(search.userId ? { userId: search.userId } : skipToken)
}),
context: ({ context: { queryClient }, search }) => ({
queryOptions: trpc.users.getUserById.queryOptions(search.userId ? { userId: search.userId } : skipToken)
}),
exotic-emerald
exotic-emeraldOP4w ago
thanks for this 🙏 so, i kinda did but i can see it's very deep nested and not obvious. i have a /$orgSlug/route.tsx which acts as organization guard. it async calls getOrgIdBySlug in beforeLoad and returns { organizationId } so this gets added to the context. in my sub-routes, i need that organizationId to build the queryOptions but it's not available in context because the context call seems to be calls before the beforeLoad of the $slug route is finished.
exotic-emerald
exotic-emeraldOP4w ago
No description
correct-apricot
correct-apricot4w ago
yeah context runs beforeLoad
exotic-emerald
exotic-emeraldOP4w ago
No description
No description
correct-apricot
correct-apricot4w ago
beforeLoad is blocking and runs on every navigation, so doing something async there is usually only recommended for auth and/or if you have it exceissively cached
exotic-emerald
exotic-emeraldOP4w ago
that is exactly what we do (auth :D) so that's why i can't use context for building the queryOptions 🙁 i could build the queryOptions in the loader and return it, but then it wouldn't be in useRouteContext but rather in useLoaderData
correct-apricot
correct-apricot4w ago
yeah it's interesting that you have slug in the path but the API needs id. I'd probably try to make getForm accept a slug and then just do the lookup in the backend or so. but you can also use beforeLoad to build queryOptions, the downside is that it's not memoized so it runs on every navigation, giving you new queryOptions, and then useRoutContext will re-render all subscribers. context is "better" for this because it depends on loaderDeps so it will not re-run for unrelated query param changes
exotic-emerald
exotic-emeraldOP4w ago
we have slug in url just for having human readable urls.. but ids are stored in db on every object or so to scope them by tenant. we use nosql db so id is binary format (ObjectId) so not text for faster indexing, db queries etc etc... i understand the implications better, what you explained is crystal clear but unfortunately i don't think i can swap slug to id or do differently. i prefer to overhead client rendering a bit knowing that react-query caches result anyway, rather than adding a "getIdBySlug" for every single request i process in BE (it's outside of scope but DB is already a bit far from BE so timings are never below 100ms which is bothering me -_- having one more request to process organizationId at the start of a handler would add a lot)
exotic-emerald
exotic-emeraldOP4w ago
(btw if you're ever interested in a feedback, this doesn't work). it seems that ensureQueryData does not support skipToken (but maybe that's expected and that's just me)
No description
No description
exotic-emerald
exotic-emeraldOP4w ago
best looking attempt is:
No description
correct-apricot
correct-apricot4w ago
oh right, the expectation for declarative functions is that you just don't cal them, that's why enabled isn't supported and skipToken is just an extension of enabled. But I think we need to start checking for this with v6 because yeah, that's not a good experience!
exotic-emerald
exotic-emeraldOP4w ago
i understand that it would not be possible (easily), but imho best would be to keep the router loader DX, it's much more powerful and concise in writing. the problem is the reactivity with react-query and the propagation to route hooks. great challenge 😬 @TkDodo 🔮 if i use useSuspenseQuery and i can't use Route.useLoaderData then, what's the benefit of the route loader part? i mean what's the benefits of:
const Route = createFileRoute(..., {
component: RouteComponent,
context: ({ params, search }) => ({
queryOptions: createQueryOptions({ params, search })
})
loader: async ({ context: { queryOptions } }) => {
queryClient.prefetchQuery(queryOptions)
}
})

function RouteComponent() {
const { queryOptions } = Route.useRouteContext()
const { data } = useSuspenseQuery(queryOptions)
}
const Route = createFileRoute(..., {
component: RouteComponent,
context: ({ params, search }) => ({
queryOptions: createQueryOptions({ params, search })
})
loader: async ({ context: { queryOptions } }) => {
queryClient.prefetchQuery(queryOptions)
}
})

function RouteComponent() {
const { queryOptions } = Route.useRouteContext()
const { data } = useSuspenseQuery(queryOptions)
}
vs
const Route = createFileRoute(..., {
component: RouteComponent,
)

function RouteComponent() {
const params = Route.useParams()
const search = Route.useSearch()
const { data } = useSuspenseQuery(createQueryOptions({ params, search }))
}
const Route = createFileRoute(..., {
component: RouteComponent,
)

function RouteComponent() {
const params = Route.useParams()
const search = Route.useSearch()
const { data } = useSuspenseQuery(createQueryOptions({ params, search }))
}
it seems to me it would do the same, no?
correct-apricot
correct-apricot4w ago
Fetching starts earlier With intent prefetching, the loader even runs when you hover the link to the page
exotic-emerald
exotic-emeraldOP4w ago
mmm i see thanks 🙂

Did you find this page helpful?