T
TanStack12mo ago
xenial-black

tanstack router + query: what's a good way to ensure queries in loader are used in component?

I'm a big fan of tanstack/query and tanstack/router. We've been using query in our 1M+ LoC frontend codebase with a trpc-like wrapper and it works great! Our queries look like this:
const { data } = api.get('/api/foo/{id}').useQuery({
id: 2,
includes: { bar: true },
})
const { data } = api.get('/api/foo/{id}').useQuery({
id: 2,
includes: { bar: true },
})
We recently replaced our router with tanstack's and I'm still trying to figure out the best practices to recommend for our devs to follow. One thing I'm unsure how to handle is keeping the queries in loader in sync with what is actually used inside the page. For example:
// route
const route = createFileRoute('/a')({
loader: () => {
api.get('/api/foo').fetchQuery({
includes: { bar: true },
})
},
component: Component
})

// component
const Component = () => {
const { data } = api.get('/api/foo').useQuery({
includes: { bar: true },
})
}
// route
const route = createFileRoute('/a')({
loader: () => {
api.get('/api/foo').fetchQuery({
includes: { bar: true },
})
},
component: Component
})

// component
const Component = () => {
const { data } = api.get('/api/foo').useQuery({
includes: { bar: true },
})
}
My worry is that some day a developer will need to change a query parameter in Component, (e.g. "we don't need to include bar anymore for this feature"), and will forget to update the related loader because it is easy to miss that those 2 things are related. (A change in includes means that the queryKey is different, and so it's a whole other query, we just fetch something in the loader that never gets used) The possible solutions: 1. actually return stuff from the loader, and use route.useLoaderData to access it
issues: loader is not reactive, so this is another invalidation we need to maintain
2. export the queryOptions from a single place and use it in both the loader and the component
issues: we used to do this, but queries became distant from their uses, and could have unnecessary params left over (like includes), because it wasn't obvious they weren't necessary anymore
3. not use the loader and go all in on useSuspenseQuery
issues: we might cause waterfalls, and the "preload" will be less useful
4. just be rigorous and keep them in sync
issues: easier said than done
11 Replies
xenial-black
xenial-blackOP12mo ago
I can absolutely go into more details about anything in my question if something is unclear. I had to keep it brief for the character limit. Also, I'm sure this is not a question with a definitive answer (though i'd love one) so I'm open to discussion, including question that would make me rethink the way we do queries overall. Also, I'm unsure whether this question should have been asked here or in #router-questions so I'll leave it here for now, and might delete+repost on the other chan. I don't want to spam / duplicate.
sunny-green
sunny-green12mo ago
based on all the discussions/examples I've seen and my < 1 month experience with router/start, the best way imho is to do it like (2) in your list: 1) build the query options inside beforeLoad and make them part of the context (i.e. just return them from beforeLoad) 2) use the options from the context in the loader 3) use the options from the context in the component 4) Use ensureQueryData in the loader, useSuspenseQuery in the component. My 2c on waterfalls: sometimes they're unavoidable, so I like to move them to the server side and handle the frontend needs in one request/response cycle building the options in beforeLoad still keeps them close to the place they're used
xenial-black
xenial-blackOP12mo ago
That's an interesting approach I hadn't thought of and I quite like it. But it still feels like it doesn't quite address the issue I wrote for (2) which is that writing the query options "far" from where the data is actually used makes it easy to forget to update it if something changes. Otherwise I like this idea. I'd probably create the options in the loader instead though, because our biggest app has something like 300 routes, and so putting too much stuff in the beforeLoad might bloat the main chunk. But otherwise it seems like creating the query options in the route itself might be nice.
sunny-green
sunny-green12mo ago
why would it bloat the main chunk though? each route has their own beforeLoad
xenial-black
xenial-blackOP12mo ago
We're using auto code-splitting with file routes, and i think the beforeLoad functions get bundled with the route tree and so end up in the main JS chunk, whereas the loader functions get bundled with their route, and so are lazy loaded. I'm might be wrong on this, but I think this is what I observed.
sunny-green
sunny-green11mo ago
got you, wasn't aware of that. thanks
wise-white
wise-white11mo ago
What @Kaan writes is what I've been doing as well and it's really good instead of beforeLoad, I'd use the context method though (quite new, but made specifically for this to amend the context)
might bloat the main chunk.
loaders and beforeLoad can't be code-split, because it needs to potentially run before the user navigated to the route. It's deemed "essential" data, and there usually isn't much you'd save with that anyways. It's likely just queryOptions and api calls, so there isn't much bloat
xenial-black
xenial-blackOP11mo ago
My confidence level here is pretty low seeing as I've only just started using the router. But I'm looking at the production build currently served from AWS and I see this for an example of loader
, Kdt = () => R( () => import("./migration-CAIurVOA.js"), __vite__mapDeps([543, 295, 66, 10, 11, 9, 544, 484, 256, 22, 23, 208, 101, 36]))
, Ydt = () => R( () => import("./migration-CAIurVOA.js"), __vite__mapDeps([543, 295, 66, 10, 11, 9, 544, 484, 256, 22, 23, 208, 101, 36]))
, jre = A("/_preload/_auth_layout/_commonhold_layout/banking/migration")({
component: T(Ydt, "component", () => jre.ssr),
loader: jb(Kdt, "loader")
, Kdt = () => R( () => import("./migration-CAIurVOA.js"), __vite__mapDeps([543, 295, 66, 10, 11, 9, 544, 484, 256, 22, 23, 208, 101, 36]))
, Ydt = () => R( () => import("./migration-CAIurVOA.js"), __vite__mapDeps([543, 295, 66, 10, 11, 9, 544, 484, 256, 22, 23, 208, 101, 36]))
, jre = A("/_preload/_auth_layout/_commonhold_layout/banking/migration")({
component: T(Ydt, "component", () => jre.ssr),
loader: jb(Kdt, "loader")
It looks like the Kdt function is lazy loaded through import() right? Whereas for a beforeLoad:
, Pre = A("/_preload/_auth_layout/_commonhold_layout/marketplace/pj_gci_insurance_charac")({
component: T(Ddt, "component", () => Pre.ssr),
beforeLoad: async () => {
const {ff_pj_gci_insurance: e} = await ft();
if (!e)
throw he();
, Pre = A("/_preload/_auth_layout/_commonhold_layout/marketplace/pj_gci_insurance_charac")({
component: T(Ddt, "component", () => Pre.ssr),
beforeLoad: async () => {
const {ff_pj_gci_insurance: e} = await ft();
if (!e)
throw he();
Here we see it's inlined in the router definition.
dependent-tan
dependent-tan8mo ago
Just want to revive this as I came to a similar approach a few days ago, would this actually be feasible/safe to do? https://discord.com/channels/719702312431386674/1007702008448426065/1365339618442084463 I've never encountered the Route.context method though, and honestly struggle to find it in the docs
dependent-tan
dependent-tan8mo ago
This is the PR fwiw, can't find any documentation about it
wise-white
wise-white8mo ago
Putting queryOptions on the route context, then using them from the and the loader and in the component with useRouteContext is a good way

Did you find this page helpful?