"بطل تتصارع مع الـ Router بتاعك. TanStack Router بيديك Navigation آمن 100% بالـ Type Safety، وFile-based Routing، وBuilt-in Data Loading، وإدارة للـ URL State بتشتغل فعلًا زي ما تتوقع. في المقال ده هتلاقي كل حاجة محتاج تعرفها."
ترميز سعيد! — Ahmed Fahmy
كنت هناك. بتكتب مسار route، بتحفظ، وكل حاجة تبان تمام. وبعدين في runtime—404. أو أسوأ من كده، بتنتقل لـ /users/profle بدل /users/profile وتقضي 20 دقيقة بتتساءل ليه الصفحة فاضية.
React Router مش بيمسك ده. ما يقدرش. ما بيعرفش الـ routes بتاعتك في وقت الـ compile.
TanStack Router بيعرف.
// React Router — ما في error، URL غلط، حظ سعيد في الـ debugging
navigate("/users/profle/123");
// TanStack Router — TypeScript error فوراً
navigate({ to: "/users/$userId", params: { userId: "123" } });
// ↑ autocomplete بيشتغل هنا
// ↑ مسار غلط = compile error
ده الفرق. وبيروح أعمق بكتير من مجرد المسارات.
TanStack Router هو Client-side Router لـ React مبني بالكامل حول مفهوم الـ Type Safety.
لكنه مش مجرد Router عادي… بل يعتبر حل متكامل لإدارة الـ Routing داخل التطبيق، لأنه بيوفر:
TypeScript inference 100% — سواء للـ Paths أو Params أو Search Params، كله Typed بشكل كامل
File-based routing — هيكلة الملفات عندك هي نفسها هيكلة الـ Routes
Built-in Data Loading — تقدر تعمل تحميل للبيانات قبل ما الـ Component يترندر
Type-safe Search Params — إدارة للـ URL State مع Validation باستخدام Zod
Route context — مرر بيانات في شجرة الـ routes من غير prop drilling
Built-in SWR Caching — بيانات الـ Loaders بتتخزن وتتحدث تلقائيًا
Devtools — لوحة مخصصة لفحص الـ routes والـ params وحالة الـ loader
npm install @tanstack/react-router
npm install -D @tanstack/router-plugin @tanstack/router-devtools
// vite.config.ts
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import { TanStackRouterVite } from "@tanstack/router-plugin/vite";
export default defineConfig({
plugins: [
TanStackRouterVite({ target: "react", autoCodeSplitting: true }),
react(),
],
});
الـ plugin بيراقب مجلد src/routes/ وبيولد routeTree.gen.ts تلقائياً. ما بتلمسش الملف ده أبداً.
// src/main.tsx
import { createRouter, RouterProvider } from '@tanstack/react-router'
import { routeTree } from './routeTree.gen'
const router = createRouter({
routeTree,
defaultPreload: 'intent', // preload عند hover
})
// سجّل عشان تحصل على type inference كامل في كل مكان
declare module '@tanstack/react-router' {
interface Register {
router: typeof router
}
}
function App() {
return <RouterProvider router={router} />
}
الـ declare module ده هو اللي بيعمل السحر. بعده، كل Link وuseNavigate وuseParams في تطبيقك بالكامل بيكون Typed بالكامل.
هيكل الملفات تحت src/routes/ بيتحول مباشرة لهيكل الـ URL.
src/routes/
├── __root.tsx → root layout (بيتعرض دايماً)
├── index.tsx → /
├── about.tsx → /about
├── posts/
│ ├── index.tsx → /posts
│ └── $postId.tsx → /posts/:postId
└── _authenticated/
├── _authenticated.tsx → layout (من غير URL segment)
└── dashboard.tsx → /dashboard
الـ root route هو الغلاف اللي بيلف كل حاجة. بيتعرض دايماً.
// src/routes/__root.tsx
import { createRootRoute, Outlet, Link } from '@tanstack/react-router'
export const Route = createRootRoute({
component: () => (
<div>
<nav>
<Link to="/">الرئيسية</Link>
<Link to="/posts">المقالات</Link>
<Link to="/dashboard">لوحة التحكم</Link>
</nav>
<main>
<Outlet /> {/* الـ child routes بتتعرض هنا */}
</main>
</div>
),
notFoundComponent: () => <div>404 — الصفحة مش موجودة</div>,
})
// src/routes/about.tsx
import { createFileRoute } from '@tanstack/react-router'
export const Route = createFileRoute('/about')({
component: AboutPage,
})
function AboutPage() {
return <h1>عن الموقع</h1>
}
استخدم علامة $ قبل اسم الـ Segment علشان تعمل Dynamic Route:
// src/routes/posts/$postId.tsx
import { createFileRoute } from '@tanstack/react-router'
export const Route = createFileRoute('/posts/$postId')({
component: PostPage,
})
function PostPage() {
const { postId } = Route.useParams() // Typed بالكامل
return <h1>المقالة: {postId}</h1>
}
هنا TanStack Router بيتألق فعلاً. الـ loaders بتشتغل قبل ما المكون يتعرض. مفيش spinners عند أول تحميل. مفيش shells فاضية.
// src/routes/posts/$postId.tsx
import { createFileRoute } from '@tanstack/react-router'
async function fetchPost(postId: string) {
const res = await fetch(`/api/posts/${postId}`)
if (!res.ok) throw new Error('المقالة مش موجودة')
return res.json()
}
export const Route = createFileRoute('/posts/$postId')({
loader: async ({ params }) => {
return fetchPost(params.postId)
},
pendingComponent: () => <div>جاري التحميل...</div>,
errorComponent: ({ error }) => <div>خطأ: {error.message}</div>,
component: PostPage,
})
function PostPage() {
const post = Route.useLoaderData() // البيانات هنا Typed تلقائيًا من الـ Loader
return (
<article>
<h1>{post.title}</h1>
<p>{post.content}</p>
</article>
)
}
بيانات الـ Loader بتتخزن تلقائيًا في الـ Cache.
يعني لو تنقلت لصفحة تانية ورجعت مرة ثانية، البيانات هتكون موجودة بالفعل بدون إعادة Fetch… إلا لو كانت Stale واحتاجت تتحدث من جديد.
لو بتستخدم TanStack Query بالفعل، التكامل سلس. استخدم الـ loader للـ prefetch، وuseQuery للاستهلاك:
// src/routes/posts/$postId.tsx
import { createFileRoute } from '@tanstack/react-router'
import { queryOptions, useSuspenseQuery } from '@tanstack/react-query'
const postQueryOptions = (postId: string) =>
queryOptions({
queryKey: ['posts', postId],
queryFn: () => fetch(`/api/posts/${postId}`).then(r => r.json()),
})
export const Route = createFileRoute('/posts/$postId')({
loader: ({ params, context: { queryClient } }) =>
queryClient.ensureQueryData(postQueryOptions(params.postId)),
component: PostPage,
})
function PostPage() {
const { postId } = Route.useParams()
const { data: post } = useSuspenseQuery(postQueryOptions(postId))
return <h1>{post.title}</h1>
}
الـ loader بيضمن إن البيانات في الـ cache قبل ما المكون يتعرض. useSuspenseQuery بيقراها. مفيش double fetch. مفيش loading state.
الـ Search Params تعتبر من أكثر المميزات المظلومة في عالم الـ Routing.
معظم التطبيقات تعتمد على useState لإدارة الـ Filters أو Pagination أو الـ Sort Order… لكن أول ما المستخدم يعمل Refresh، كل الـ State دي بتضيع.
TanStack Router بيتعامل مع الـ search params كـ state من الدرجة الأولى. Validated وTyped وSerialized، ومتسلسلة تلقائياً.
// src/routes/posts/index.tsx
import { createFileRoute, useNavigate } from '@tanstack/react-router'
import { z } from 'zod'
import { fallback } from '@tanstack/zod-adapter'
const postsSearchSchema = z.object({
page: fallback(z.number(), 1).default(1),
category: fallback(z.string(), 'all').default('all'),
sort: fallback(z.enum(['newest', 'oldest', 'popular']), 'newest').default('newest'),
})
export const Route = createFileRoute('/posts/')({
validateSearch: postsSearchSchema,
component: PostsPage,
})
function PostsPage() {
const { page, category, sort } = Route.useSearch()
const navigate = useNavigate({ from: Route.fullPath })
return (
<div>
<select
value={sort}
onChange={(e) =>
navigate({
search: (prev) => ({ ...prev, sort: e.target.value, page: 1 }),
})
}
>
<option value="newest">الأحدث</option>
<option value="oldest">الأقدم</option>
<option value="popular">الأكثر شعبية</option>
</select>
{/* Pagination */}
<Link search={(prev) => ({ ...prev, page: page + 1 })}>
الصفحة التالية ←
</Link>
</div>
)
}
الـ URL بيبقى /posts?page=2&category=react&sort=newest. إعمل refresh للصفحة؟ كل حاجة لسه موجودة. شارك الـ URL؟ الشخص التاني بيشوف نفس العرض المفلتر.
ولو حد كتب ?page=abc يدوياً في الـ URL؟ الـ fallback بيمسكها وبيرجع للقيمة الافتراضية. ما في crashes.
الـ route context بيخليك تحقن بيانات في أي مستوى من شجرة الـ routes. كل الـ child routes تقدر توصلها.
أكثر استخدام شائع: تمرير queryClient وحالة الـ auth لكل الـ routes.
// src/main.tsx
const router = createRouter({
routeTree,
context: {
queryClient,
auth: undefined!, // هيتحدد من الـ provider
},
});
// src/routes/__root.tsx
import { createRootRouteWithContext } from "@tanstack/react-router";
import type { QueryClient } from "@tanstack/react-query";
interface RouterContext {
queryClient: QueryClient;
auth: { isAuthenticated: boolean; user: User | null };
}
export const Route = createRootRouteWithContext<RouterContext>()({
component: RootLayout,
});
دلوقتي كل route في تطبيقك تقدر توصل queryClient وauth من الـ context:
// src/routes/posts/$postId.tsx
export const Route = createFileRoute("/posts/$postId")({
loader: ({ params, context: { queryClient } }) =>
queryClient.ensureQueryData(postQueryOptions(params.postId)),
});
وتقدر كمان تضيف للـ context في أي route وتمرره للأسفل:
// src/routes/_authenticated.tsx
export const Route = createFileRoute('/_authenticated')({
beforeLoad: ({ context }) => {
if (!context.auth.isAuthenticated) {
throw redirect({ to: '/login' })
}
// أضف المستخدم للـ context لكل الـ child routes
return { user: context.auth.user }
},
})
// src/routes/_authenticated/dashboard.tsx
function DashboardPage() {
const { user } = Route.useRouteContext() // فيها user من الـ parent
return <h1>أهلاً، {user.name}</h1>
}
أحياناً بتحتاج layout مشترك أو منطق مشترك لمجموعة routes، من غير ما تضيف URL segment. ده اللي الـ pathless routes موجودة عشانه.
سمّي الملف بـ underscore كبادئة: _authenticated.tsx. بيلف كل الـ routes جوا _authenticated/ لكن ما بيضيفش حاجة للـ URL.
// src/routes/_authenticated.tsx
import { createFileRoute, redirect, Outlet } from '@tanstack/react-router'
export const Route = createFileRoute('/_authenticated')({
beforeLoad: ({ context, location }) => {
if (!context.auth.isAuthenticated) {
throw redirect({
to: '/login',
search: { redirect: location.href }, // احفظ الـ URL الحالي
})
}
return { user: context.auth.user }
},
component: () => <Outlet />, // أو مكون layout كامل
})
أي route جوا src/routes/_authenticated/ دلوقتي محمي. مفيش تكرار. مفيش نسيان إضافة auth check.
استخدم الأقواس لتجميع الـ routes للتنظيم من غير ما تأثر على الـ URL:
src/routes/
├── (auth)/
│ ├── login.tsx → /login
│ ├── register.tsx → /register
│ └── forgot-password.tsx → /forgot-password
└── (marketing)/
├── index.tsx → /
└── about.tsx → /about
مجلدات (auth) و(marketing) مش موجودة في الـ URL. هي بس لتنظيم ملفاتك.
Link مكتوب بالـ Type Safety بالكامل. لو كتبت Path غلط → TypeScript هيظهر Error فورًا، لو نسيت Param مطلوب → TypeScript هيظهر Error أيضًا
import { Link } from '@tanstack/react-router'
function Navigation() {
return (
<nav>
{/* Link بسيط */}
<Link to="/about">عن الموقع</Link>
{/* Dynamic param — TypeScript بيطلب postId */}
<Link to="/posts/$postId" params={{ postId: '123' }}>
عرض المقالة
</Link>
{/* مع search params */}
<Link to="/posts" search={{ page: 1, category: 'react' }}>
مقالات React
</Link>
{/* حدّث search params من غير ما تخسر التانيين */}
<Link to="." search={(prev) => ({ ...prev, page: 2 })}>
الصفحة التالية
</Link>
{/* Active styling */}
<Link
to="/dashboard"
activeProps={{ className: 'font-bold text-blue-600' }}
inactiveProps={{ className: 'text-gray-500' }}
>
لوحة التحكم
</Link>
{/* Preload عند hover */}
<Link to="/posts" preload="intent">
المقالات
</Link>
</nav>
)
}
خيار preload="intent" قوي. لما المستخدم بيحوم فوق link، TanStack Router بيبدأ يحمل البيانات للـ route ده. لحظة ما بيضغط، البيانات موجودة بالفعل.
استخدم useNavigate لما تحتاج تنتقل بعد عملية async:
import { useNavigate } from '@tanstack/react-router'
function CreatePostForm() {
const navigate = useNavigate()
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault()
const formData = new FormData(e.currentTarget)
const res = await fetch('/api/posts', {
method: 'POST',
body: JSON.stringify({ title: formData.get('title') }),
})
if (res.ok) {
const { id } = await res.json()
navigate({
to: '/posts/$postId',
params: { postId: id },
replace: true, // ما تضيفش للـ history
})
}
}
return (
<form onSubmit={handleSubmit}>
<input name="title" placeholder="عنوان المقالة" />
<button type="submit">إنشاء</button>
</form>
)
}
كل route تقدر تعرف error boundary خاص بيها. ما في حاجة تلف المكونات يدوياً.
export const Route = createFileRoute('/posts/$postId')({
loader: async ({ params }) => {
const res = await fetch(`/api/posts/${params.postId}`)
if (!res.ok) throw new Error('المقالة مش موجودة')
return res.json()
},
errorComponent: ({ error, reset }) => (
<div>
<h2>حصل حاجة غلط</h2>
<p>{error.message}</p>
<button onClick={reset}>حاول تاني</button>
</div>
),
component: PostPage,
})
لو الـ loader رمى error، errorComponent بيتعرض. لو المكون رمى error، نفس الحاجة. باقي تطبيقك بيفضل شغال.
TanStack Router بيجي مع devtools مخصصة. ضفهم في التطوير:
// src/routes/__root.tsx
import { lazy, Suspense } from 'react'
const TanStackRouterDevtools = lazy(() =>
import('@tanstack/react-router-devtools').then((m) => ({
default: m.TanStackRouterDevtoolsPanel,
}))
)
export const Route = createRootRoute({
component: () => (
<>
<Outlet />
{import.meta.env.DEV && (
<Suspense>
<TanStackRouterDevtools />
</Suspense>
)}
</>
),
})
الـ devtools بتوريك كل route، الـ params بتاعتها، الـ search params، حالة الـ loader، والـ context. لا غنى عنها في الـ debugging.
الميزة | TanStack Router | React Router v6 |
|---|---|---|
TypeScript path inference | ✅ كامل | ❌ مفيش |
Params آمنة من الأنواع | ✅ | ❌ |
Search params آمنة من الأنواع | ✅ مع Zod | ❌ |
تحميل بيانات مدمج | ✅ Loaders | ✅ Loaders |
Loader caching (SWR) | ✅ مدمج | ❌ |
File-based routing | ✅ | ❌ (محتاج Remix) |
Route context | ✅ | ❌ |
Search param validation | ✅ Zod | ❌ |
Devtools | ✅ | ❌ |
الفجوة حقيقية. لو بتبني تطبيق TypeScript، TanStack Router هو الاختيار الأفضل.
هنا هيكل حقيقي بيجمع كل حاجة:
src/routes/
├── __root.tsx → root layout + devtools
├── index.tsx → / (الصفحة الرئيسية)
├── (auth)/
│ ├── login.tsx → /login (بيعمل redirect لو مسجل دخول)
│ └── register.tsx → /register
└── _authenticated/
├── _authenticated.tsx → auth guard + user context
├── dashboard.tsx → /dashboard
└── posts/
├── index.tsx → /posts (مع search params: page, category)
└── $postId.tsx → /posts/:postId (مع loader)
// src/routes/_authenticated/posts/index.tsx
import { createFileRoute, useNavigate, Link } from '@tanstack/react-router'
import { queryOptions, useSuspenseQuery } from '@tanstack/react-query'
import { z } from 'zod'
import { fallback } from '@tanstack/zod-adapter'
const postsSearchSchema = z.object({
page: fallback(z.number(), 1).default(1),
category: fallback(z.string(), 'all').default('all'),
})
const postsQueryOptions = (page: number, category: string) =>
queryOptions({
queryKey: ['posts', { page, category }],
queryFn: () =>
fetch(`/api/posts?page=${page}&category=${category}`).then(r => r.json()),
})
export const Route = createFileRoute('/_authenticated/posts/')({
validateSearch: postsSearchSchema,
loader: ({ context: { queryClient }, deps: { page, category } }) =>
queryClient.ensureQueryData(postsQueryOptions(page, category)),
loaderDeps: ({ search: { page, category } }) => ({ page, category }),
component: PostsPage,
})
function PostsPage() {
const { page, category } = Route.useSearch()
const navigate = useNavigate({ from: Route.fullPath })
const { data: posts } = useSuspenseQuery(postsQueryOptions(page, category))
return (
<div>
<h1>المقالات</h1>
<select
value={category}
onChange={(e) =>
navigate({ search: (prev) => ({ ...prev, category: e.target.value, page: 1 }) })
}
>
<option value="all">الكل</option>
<option value="react">React</option>
<option value="typescript">TypeScript</option>
</select>
<ul>
{posts.map((post) => (
<li key={post.id}>
<Link to="/posts/$postId" params={{ postId: post.id }}>
{post.title}
</Link>
</li>
))}
</ul>
<div>
<button
disabled={page <= 1}
onClick={() => navigate({ search: (prev) => ({ ...prev, page: page - 1 }) })}
>
السابق
</button>
<span>صفحة {page}</span>
<button
onClick={() => navigate({ search: (prev) => ({ ...prev, page: page + 1 }) })}
>
التالي
</button>
</div>
</div>
)
}
TanStack Router بيحل المشاكل اللي كانت بتزعج مطوري React لسنين:
أخطاء الـ Paths أثناء التشغيل → تتحول إلى Compile-time Errors
ضياع حالة الـ URL بعد الـ Refresh → Search Params آمنة ومكتوبة بالـ Type Safety
Loading Spinners في كل مكان → الـ Loaders تعمل قبل الـ Render
Prop Drilling لحالة الـ Auth → Route Context
Routes غير محمية → beforeLoad Guards
إذا كنت تبدأ مشروع React جديد، فـ TanStack Router يعتبر خيارًا ممتازًا من البداية.
ولو كنت تستخدم React Router وتعبت من مشاكل الـ Routing أثناء التشغيل، ففكرة الانتقال إليه تستحق التجربة فعلًا.
الـ type safety لوحدها تستاهل.
المصادر: