Disclaimer upfront: I love tanstack query. High quality, well-thought-out API, does not get in the way and I generally do not really run into walls from my experience. I did not use tanstack router before seriously, but I have now tried to evaluate it for how to make it work in Sentry and how it could be used for a similar type of project. TLDR: I keep running into issues. I think tanstack router is great, probably my issue is just a result of thinking about this differently than tanstack router wants.
So this is some raw feedback, use it as you will.
I love what it tries to do, but at the same time it's way too much TypeScript stuff. The first one is that there really can only be one router at the time because of the declare module stuff:
declare module "@tanstack/react-router" {
interface Register {
router: typeof router;
}
}
This is a nice idea, but it's I think abusing TypeScript and it means that if you were to declare a second router, you get TypeScript errors all over the place.
Ctrl+click onto a thing usually also ends up in completely unreadable type script
declarations. For instance if you want to know how createFileRoute
works and you
click on it, you end up on this unreadable mess:
export declare function createFileRoute<TFilePath extends keyof FileRoutesByPath, TParentRoute extends AnyRoute = FileRoutesByPath[TFilePath]['parentRoute'], TId extends RouteConstraints['TId'] = FileRoutesByPath[TFilePath]['id'], TPath extends RouteConstraints['TPath'] = FileRoutesByPath[TFilePath]['path'], TFullPath extends RouteConstraints['TFullPath'] = FileRoutesByPath[TFilePath]['fullPath']>(path: TFilePath): FileRoute<TFilePath, TParentRoute, TId, TPath, TFullPath>['createRoute'];
What should I do with this? I absolutely cannot read or reason about it.
I love that it supports suspense. It's a great way to handle loading states. What
i do not like at all is that it magically does it around every Outlet
. If you want
to wrap the Outlet
in a React.Suspense
that never does anything. Instead you are
basically forced to use defaultPendingComponent
. This is at the very least super
confusing and it was not clear enough from the documentation.
This is not so much a router problem, as it's just the classic issue of sharing code
from within react with the outside world. In react you can use a provider to pass
down the query client (eg: QueryClientProvider
) and then use the useQuery
hook
to get it back.
One of the consequences of that is that react query code generally can just use useQuery
and useSuspenseQuery
from anywhere. However the tanstack router also encourages you
to use loader
to ensure that query data is available before the route is loaded. But
the loader works independently of react, so now you need to get the query client down
there somehow. Unlike useQuery
which uses this implicitly from the context, the router
uses a completely different mechanism. You are supposed to type out your context manually
and then pass it down. Eg:
const queryClient = new QueryClient();
const routerContext = { queryClient };
const router = createRouter({
context: routerContext,
});
export type RouterContext = typeof routerContext;
export const Route = createRootRouteWithContext<RouterContext>()({
component: RootComponent,
});
This is not as composable as it means that you need to know which router context actually exists, which key on it. This is probably okay as you are most likely going to be using this within your application, but it now creates two distinct worlds. You then end up with a weird situation where the code normally looks like this:
const queryOptions = {
queryKey: ["api", "messages"],
queryFn: () => fetch("/api/messages").then((res) => res.json()),
staleTime: 60,
};
export const Route = createFileRoute("/messages")({
component: MessagesComponent,
loader: (opts) => {
// the loader gets the query client by router context
opts.context.queryClient.ensureQueryData(queryOptions);
}
});
export function MessagesComponent() {
// whereas the component uses the query client from react context system
const { data } = useSuspenseQuery(queryOptions);
return (
<Panel title="Messages">
<pre>{JSON.stringify(data, null, 2)}</pre>
</Panel>
);
}
I do not know what a better solution would be, but I find this to be annoyingly clunky. All of this goes away if you just don't use dependency injection at all and just import the client from some known place and prentend that's okay. Which maybe it is?
React router uses IDs internally to refer to routes, but you don't really get way to name them. That's because if you create links, you do not use the IDs to refer to the routes but the path names. That's in some situations really annoying. For instance if you refactor your routes around, you end up with different ids and paths and a links break. Maybe not too much of a deal, but it becomes a problem if the routes are dynamic-ish.
For instance take Sentry. In Sentry we have really two ways to run the frontend. One is to have
the customer organization on the subdomain (<customer>.sentry.io
) and the other is to have it
as part of the path (eg: installation/<customer>
). However there is not such a clear 1:1 mapping
between the two. For instance there are some URLs that look like installation/organizations/<customer>
.
That's because the URL scheme predates our on-subdomain installation. You might think we moved fully
to subdomains but we did not. That's because Sentry also maintains single-tenant installations where
a customer is alone on an installation (but might have more than one organization) and we also have
local development where subdomains are not in use.
Now tanstack router today does not have a way to route based on subdomains at all, but the type safety in combination with not being able to influence the router puts you into a weird spot. What I really want is to just say: here are my routes, but the paths are dynamic based on some parameters. Today I cannot do that because Tanstack Router does not give me a way to explictly name the routes.
So what I would really want is to have a way to use file based routing for most of my routes, but
then be able to only use explicit IDs for all routes (and use that for to
/from
). But then have
a separate stage where I can hook into the router code genration to optionally wrap it or swap it.
So if you do end up with code generated routes (which is the only way to explicitly ID them), you
need to hack around Link
being based on paths.
I don't mind the routeTree.gen.tsx
but I do mind that it keeps editing other files. If the IDs were
unique and stable there was no need to edit around in files. I would rather have a
createFileRouteWithId("unique-id")
than createFileRoute("path-that-is-edited")
and router not edit
around in my files.
The way to generate routes from code is quite clunky due to the desire to make TypeScript happy. This
is particularly annoying when you try to have some routes to be partially dynamic in nature (eg: the
Sentry usecase). You really have to call addChildren
once as otherwise nothing really works. The
way to not lose motivation here entirely means that one path is to just create a massive JSON file and
us custom code generation to generate the route tree instead.
It's pretty clear that code based routes are not intended to be used, but the current state of file based routes means that you might have to? At least I don't really see a way to make the file based routing work with the desire above of having dynamic routes.
- Please for the love of all parsing: use semicolons in the docs.
- I keep running into 404s all over the docs, I don't remember this from the past.
I still need to write better docs, but https://github.com/swan-io/chicane doesn't suffer from most of this, despite being typesafe too.
That might be what you are looking for.