Migrando react-router-dom para TanStack router
Data de publicação

Visão geral
Usar react-router-dom e TanStack Query (React Query) no mesmo projeto sempre me gerou atrito. O roteador não conhece o queryClient; não há loaders que se comuniquem com o cache. Para ter prefetch na navegação eu teria que chamar queryClient.prefetchQuery manualmente em useEffect ou em event handlers de Link, repetindo queryKeys e queryFns. Loading e erro ficavam só no componente, e o cache do Query e o “dado da rota” viviam separados — mesma requisição, duas fontes de verdade.
Por isso decidi migrar um projeto pessoal para o TanStack Router. Preferi ele principalmente pela facilidade de integração com o Query, pela remoção das lógicas manuais dentro dos componentes (aqueles if (isLoading), if (error), if (!data) em toda página) e por conseguir tratar o cache de forma eficiente em conjunto com o roteamento.
A mudança principal foi trocar o react-router-dom por um roteador da mesma família do Query, com integração de entre as libs: o router recebe o queryClient no context, e cada rota pode ter um loader que usa os mesmos queryOptions que o useQuery usa nos componentes. Assim, o loader preenche o cache antes da tela montar; qualquer componente que use useQuery com esses options (ou useLoaderData()) consome o mesmo dado, sem duplicar lógica.
Benefícios na prática
- Uma única definição de “como buscar”: queryOptions() com queryKey e queryFn é usado no loader (via ensureQueryData) e no hook (useQuery). Zero duplicação.
- Prefetch automático na navegação: ao seguir um link, o loader roda antes da transição; o cache já está quente quando o componente monta.
- Loading alinhado ao router: pendingComponent e pendingMs fazem o router mostrar skeleton enquanto o loader resolve; não preciso orquestrar isso no componente.
- 404 e erros na rota: no loader posso usar throw notFound() e notFoundComponent na rota; o mesmo queryClient que busca os dados pode ser usado para validar existência.
- Type-safety: params da rota (params.id) e o retorno do loader são tipados; o useLoaderData() da rota herda esse tipo.
Código: Antes (react-router-dom) x Depois (TanStack Router)
Antes — react-router-dom
import { BrowserRouter, Routes, Route } from "react-router-dom";
function App() { return ( <BrowserRouter> <Routes> <Route path="/" element={<HomePage />} /> <Route path="/item/:id" element={<ItemDetailPage />} /> </Routes> </BrowserRouter> );}
// ItemDetailPage.tsx — fetch só no componente, loading só aquiimport { useParams } from "react-router-dom";import { useQuery } from "@tanstack/react-query";
function itemQuery(id: string) { return fetch(`/api/items/${id}`).then((r) => r.json());}
export function ItemDetailPage() { const { id } = useParams<{ id: string }>(); const { data, isLoading, error } = useQuery({ queryKey: ["item", id], queryFn: () => itemQuery(id!), enabled: Boolean(id), });
if (isLoading) return <ItemSkeleton />; if (error) return <div>Erro ao carregar</div>; if (!data) return null;
return ( <div> <h1>{data.title}</h1> <p>{data.description}</p> </div> );}Problemas: roteador e cache desacoplados; sem prefetch na navegação; queryKey/queryFn só no componente; loading e erro tratados manualmente na página.
Depois — TanStack Router
- Definição das rotas:
// rootRoute.tsx — context com queryClientimport { QueryClient } from "@tanstack/react-query";import { createRootRouteWithContext, Outlet } from "@tanstack/react-router";
export interface RouterContext { queryClient: QueryClient;}
export const rootRoute = createRootRouteWithContext<RouterContext>()({ component: () => <Outlet />,});
// routeTree.tsx — router recebe o queryClientimport { createRouter } from "@tanstack/react-router";import { queryClient } from "@/lib/query/queryClient";import { rootRoute } from "./rootRoute";import { itemDetailRoute } from "./routes/item";
const routeTree = rootRoute.addChildren([itemDetailRoute]);
export const router = createRouter({ routeTree, context: { queryClient },});- Hook
// useGetItem.ts — queryOptions compartilhados (loader + useQuery)import { useQuery, queryOptions } from "@tanstack/react-query";
export function itemQueryOptions(id: string) { return queryOptions({ queryKey: ["item", id], queryFn: () => fetch(`/api/items/${id}`).then((r) => r.json()), });}
export function useGetItem(id: string) { return useQuery({ ...itemQueryOptions(id), enabled: Boolean(id) });}- Componentes que compõe a rota
// routes/item.tsx — loader usa o mesmo queryOptionsimport { createRoute, notFound } from "@tanstack/react-router";import { itemQueryOptions } from "@/lib/query/useGetItem";import { rootRoute } from "@/lib/router/rootRoute";import { ItemDetailPage } from "@/components/pages/ItemDetailPage";import { ItemSkeleton } from "@/components/ItemSkeleton";
export const itemDetailRoute = createRoute({ getParentRoute: () => rootRoute, path: "/item/$id", component: ItemDetailPage, pendingComponent: ItemSkeleton, pendingMs: 200, loader: async ({ context: { queryClient }, params }) => { const data = await queryClient.ensureQueryData(itemQueryOptions(params.id)); if (!data) throw notFound(); return data; }, notFoundComponent: () => <h1>Item não encontrado</h1>,});No código acima, tem a configuração básica para os estados de loading, erro e sucesso. Sem precisar de manipulação direta no componente da página propriamente dito.
- Página do item
// ItemDetailPage.tsx — só consome dado já no cacheimport { itemDetailRoute } from "@/lib/router/routeTree";
export function ItemDetailPage() { const data = itemDetailRoute.useLoaderData();
return ( <div> <h1>{data.title}</h1> <p>{data.description}</p> </div> );}Vantagens: uma única definição (itemQueryOptions); prefetch no loader; loading via pendingComponent; 404 via notFound(); componente sem isLoading/error; mesmo cache se outro componente usar useGetItem(id).
Toda a lógica de como a página vai se comportar fica centralizada em um único arquivo e sem a necessidade de fazer vários itens encadeados.
Se você busca praticidade na hora de definir uma rota e quer evitar surpresas de como a página vai funcionar em caso de falha de requisição ou estado de loading, vale a pena dar uma chance a essa dupla. Estou usando em um projeto pessoal e até agora não houveram surpresas, além da curva de aprendizagem ser bem rápida e com uma documentação bem completa.