Migración completa de React Router v6 → TanStack Router con tipado end-to-end
import { defineConfig } from 'vite' import react from '@vitejs/plugin-react' import { TanStackRouterVite } from '@tanstack/router-plugin/vite' export default defineConfig({ plugins: [ // ⚡ Genera routeTree.gen.ts automáticamente TanStackRouterVite(), react(), ], })
import { createRouter, RouterProvider } from '@tanstack/react-router' import { routeTree } from './routeTree.gen' // auto-generado // Router completamente tipado const router = createRouter({ routeTree, defaultPreload: 'intent', }) // Registro de tipos global declare module '@tanstack/react-router' { interface Register { router: typeof router } } createRoot(document.getElementById('root')!) .render(<RouterProvider router={router} />)
import { createFileRoute, Link } from '@tanstack/react-router' import { z } from 'zod' import { fetchLeads } from '~/api/leads' // ── Schema Zod con defaults seguros ────────────────────────────────── const leadsSearchSchema = z.object({ page: z.number().int().positive().catch(1), status: z.enum(['all','new','contacted','qualified','closed']).catch('all'), assignee: z.string().uuid().optional(), search: z.string().optional(), }) export type LeadsSearch = z.infer<typeof leadsSearchSchema> // ── Definición de ruta ──────────────────────────────────────────────── export const Route = createFileRoute('/leads/')({ validateSearch: leadsSearchSchema, // 🔒 Zod valida la URL loaderDeps: ({ search }) => ({ search }), // re-fetch solo si cambian loader: async ({ deps: { search } }) => fetchLeads(search), // datos antes de renderizar component: LeadsPage, }) function LeadsPage() { // ✅ Tipos inferidos automáticamente del loader const { leads, total, pages } = Route.useLoaderData() // ^? { leads: Lead[], total: number, pages: number } const { page, status, search } = Route.useSearch() // ^? LeadsSearch — NO es string | undefined const navigate = Route.useNavigate() return ( <div className="leads-page"> <input value={search ?? ''} onChange={e => navigate({ search: { search: e.target.value, page: 1 } }) } /> {leads.map(lead => ( // ✅ Compile error si leadId no se pasa <Link key={lead.id} to="/leads/$leadId" params={{ leadId: lead.id }}> {lead.name} </Link> ))} </div> ) }
import { createFileRoute, notFound, Link, Outlet } from '@tanstack/react-router' import { fetchLead } from '~/api/leads' export const Route = createFileRoute('/leads/$leadId')({ loader: async ({ params: { leadId } }) => { // leadId es string tipado — no hace falta casting const lead = await fetchLead(leadId) if (!lead) throw notFound() // → renderiza notFoundComponent return lead }, notFoundComponent: () => <div className="not-found">Lead no encontrado</div>, component: LeadProfile, }) function LeadProfile() { const lead = Route.useLoaderData() // ^? Lead — tipado desde el return del loader return ( <div> <h1>{lead.name}</h1> <p>{lead.company} — {lead.email}</p> <nav> // ✅ params tipados — TS error si falta leadId <Link to="/leads/$leadId/notes" params={{ leadId: lead.id }}> Notas </Link> </nav> <Outlet /> {/* /leads/:id/notes renderiza aquí */} </div> ) }
string | undefined
| Patrón | React Router v6 | TanStack Router | Beneficio |
|---|---|---|---|
| Params | useParams()<any> | params.leadId: string | Tipado automático, sin casting |
| Search | useSearchParams() → string|null | useSearch() → LeadsSearch | Zod valida y da defaults |
| Data | useState + useEffect fetch | loader: async () => data | Sin spinner, datos antes de render |
| Links | <Link to="/leads/detail"> | <Link to="/leads/$leadId"> | Error de compilación si ruta no existe |
| Code split | React.lazy() manual | Automático por archivo | Bundle inicial ~40% más ligero |
| Layout | <Outlet /> en componente | _layout.tsx | Separación explícita y file-based |
| 404 | Route path="*" al final | throw notFound() en loader | Por ruta, con componente propio |