يمثل React Router v7 نقطة تحول كبيرة في تطوير React. من خلال اندماجه مع Remix، أصبح إطار عمل full-stack حقيقي قادر على التعامل مع العرض من جانب الخادم (SSR) وتحميل البيانات والتعديلات — كل ذلك بواجهة برمجة موحدة وأنيقة.
في هذا الدرس، ستبني تطبيق إدارة جهات اتصال كامل باستخدام React Router v7 في وضع الإطار (Framework Mode): تحميل البيانات عبر الـ loaders، والتعديلات عبر الـ actions، والتحقق من النماذج، ومعالجة الأخطاء، والعرض من جانب الخادم.
المتطلبات الأساسية
قبل البدء، تأكد من توفر:
- Node.js 20+ مثبت على جهازك
- npm أو pnpm كمدير حزم
- معرفة أساسية بـ React و TypeScript
- إلمام بمفاهيم HTTP (GET، POST، PUT، DELETE)
ما الذي ستبنيه
تطبيق إدارة جهات اتصال يتضمن الميزات التالية:
- قائمة جهات الاتصال مع البحث الفوري
- إنشاء وتعديل وحذف جهات الاتصال
- التحقق من النماذج على جانب الخادم
- معالجة الأخطاء باستخدام Error Boundaries
- العرض من جانب الخادم (SSR) لتحسين SEO
الخطوة 1: إنشاء المشروع
يوفر React Router v7 أداة CLI لإنشاء مشروع جديد في وضع الإطار:
npx create-react-router@latest contacts-app
cd contacts-appستطرح عليك الأداة بعض الأسئلة. اختر الخيارات التالية:
- Template: Basic
- TypeScript: Yes
- Package manager: npm (أو pnpm حسب تفضيلك)
هيكل المشروع
إليك الهيكل المُنشأ:
contacts-app/
├── app/
│ ├── routes/
│ │ └── home.tsx
│ ├── root.tsx
│ ├── routes.ts
│ └── app.css
├── react-router.config.ts
├── vite.config.ts
├── tsconfig.json
└── package.json
الملفات الرئيسية هي:
| الملف | الدور |
|---|---|
react-router.config.ts | إعدادات الإطار العامة |
app/routes.ts | تعريف مسارات التطبيق |
app/root.tsx | التخطيط الجذري (HTML shell) |
app/routes/*.tsx | وحدات المسارات (المكونات، loaders، actions) |
الخطوة 2: تفعيل SSR
افتح react-router.config.ts وفعّل العرض من جانب الخادم:
import type { Config } from "@react-router/dev/config";
export default {
ssr: true,
} satisfies Config;مع ssr: true، سيقوم React Router بـ:
- تنفيذ الـ loaders على الخادم قبل عرض الصفحة
- إرسال HTML كامل إلى المتصفح (SEO أفضل، تحميل أسرع)
- ترطيب (Hydrate) الجانب العميل للتفاعلية
الخطوة 3: إنشاء نموذج البيانات
أنشئ ملف app/data/contacts.ts لمحاكاة قاعدة البيانات:
export interface Contact {
id: string;
firstName: string;
lastName: string;
email: string;
phone?: string;
notes?: string;
createdAt: string;
}
const contacts: Map<string, Contact> = new Map();
// البيانات الأولية
const initialContacts: Contact[] = [
{
id: "1",
firstName: "أميرة",
lastName: "بن علي",
email: "amira@example.com",
phone: "+216 50 123 456",
notes: "مطورة واجهات أمامية",
createdAt: "2026-01-15",
},
{
id: "2",
firstName: "يوسف",
lastName: "منصور",
email: "youssef@example.com",
phone: "+216 55 789 012",
notes: "مصمم UX/UI",
createdAt: "2026-02-01",
},
{
id: "3",
firstName: "ليلى",
lastName: "الطرابلسي",
email: "leila@example.com",
notes: "مديرة مشاريع",
createdAt: "2026-02-20",
},
];
initialContacts.forEach((c) => contacts.set(c.id, c));
let nextId = 4;
export function getContacts(query?: string): Contact[] {
let result = Array.from(contacts.values());
if (query) {
const q = query.toLowerCase();
result = result.filter(
(c) =>
c.firstName.toLowerCase().includes(q) ||
c.lastName.toLowerCase().includes(q) ||
c.email.toLowerCase().includes(q)
);
}
return result.sort(
(a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
);
}
export function getContact(id: string): Contact | undefined {
return contacts.get(id);
}
export function createContact(
data: Omit<Contact, "id" | "createdAt">
): Contact {
const id = String(nextId++);
const contact: Contact = {
...data,
id,
createdAt: new Date().toISOString().split("T")[0],
};
contacts.set(id, contact);
return contact;
}
export function updateContact(
id: string,
data: Partial<Omit<Contact, "id" | "createdAt">>
): Contact | null {
const existing = contacts.get(id);
if (!existing) return null;
const updated = { ...existing, ...data };
contacts.set(id, updated);
return updated;
}
export function deleteContact(id: string): boolean {
return contacts.delete(id);
}في تطبيق حقيقي، ستستخدم قاعدة بيانات مثل PostgreSQL مع Prisma أو Drizzle ORM. هذه الوحدة في الذاكرة كافية لتعلم مفاهيم React Router v7.
الخطوة 4: تعريف المسارات
افتح app/routes.ts وعرّف هيكل المسارات:
import { type RouteConfig, route, index, layout } from "@react-router/dev/routes";
export default [
layout("routes/layout.tsx", [
index("routes/home.tsx"),
route("contacts/new", "routes/contacts-new.tsx"),
route("contacts/:contactId", "routes/contact-detail.tsx"),
route("contacts/:contactId/edit", "routes/contact-edit.tsx"),
route("contacts/:contactId/delete", "routes/contact-delete.tsx"),
]),
] satisfies RouteConfig;كل مسار هو وحدة يمكنها تصدير loader و action ومكون افتراضي و ErrorBoundary.
الخطوة 5: إنشاء التخطيط الرئيسي
أنشئ app/routes/layout.tsx — التخطيط المشترك بين جميع الصفحات:
import { Outlet, Link, useNavigation } from "react-router";
export default function AppLayout() {
const navigation = useNavigation();
const isNavigating = navigation.state !== "idle";
return (
<div className="app-layout">
<header className="app-header">
<Link to="/" className="logo">
<h1>جهات الاتصال</h1>
</Link>
<nav>
<Link to="/contacts/new" className="btn btn-primary">
+ جهة اتصال جديدة
</Link>
</nav>
</header>
<main className={isNavigating ? "loading" : ""}>
<Outlet />
</main>
<footer className="app-footer">
<p>مبني باستخدام React Router v7</p>
</footer>
</div>
);
}مكون <Outlet /> ضروري: فهو يعرض محتوى المسار الفرعي النشط. يتيح لك hook useNavigation() اكتشاف الانتقالات وعرض حالة التحميل.
الخطوة 6: الصفحة الرئيسية مع loader والبحث
استبدل محتوى app/routes/home.tsx:
import { useLoaderData, Form, useSearchParams } from "react-router";
import { getContacts } from "~/data/contacts";
import type { Route } from "./+types/home";
export async function loader({ request }: Route.LoaderArgs) {
const url = new URL(request.url);
const query = url.searchParams.get("q") || undefined;
const contacts = getContacts(query);
return { contacts, query };
}
export default function Home() {
const { contacts, query } = useLoaderData<typeof loader>();
const [searchParams] = useSearchParams();
return (
<div className="home-page">
<div className="search-section">
<Form method="get" className="search-form">
<input
type="search"
name="q"
placeholder="ابحث عن جهة اتصال..."
defaultValue={query || ""}
aria-label="البحث عن جهات الاتصال"
/>
<button type="submit">بحث</button>
</Form>
</div>
<div className="contacts-list">
<h2>
{query
? `نتائج البحث عن "${query}" (${contacts.length})`
: `جميع جهات الاتصال (${contacts.length})`}
</h2>
{contacts.length === 0 ? (
<p className="empty-state">
{query
? "لم يتم العثور على جهات اتصال لهذا البحث."
: "لا توجد جهات اتصال بعد. أنشئ واحدة!"}
</p>
) : (
<ul className="contact-cards">
{contacts.map((contact) => (
<li key={contact.id}>
<a href={`/contacts/${contact.id}`} className="contact-card">
<div className="contact-avatar">
{contact.firstName[0]}
{contact.lastName[0]}
</div>
<div className="contact-info">
<strong>
{contact.firstName} {contact.lastName}
</strong>
<span>{contact.email}</span>
</div>
</a>
</li>
))}
</ul>
)}
</div>
</div>
);
}كيف يعمل الـ loader؟
- يتم تنفيذ الـ
loaderعلى الخادم قبل عرض المكون - يستقبل كائن
requestمع جميع معاملات طلب HTTP - البيانات المُرجعة يتم تسلسلها تلقائياً ويمكن الوصول إليها عبر
useLoaderData() - مكون
<Form method="get">يرسل نموذج GET يحدّث معاملات البحث بدون إعادة تحميل كاملة
الخطوة 7: نموذج الإنشاء مع action
أنشئ app/routes/contacts-new.tsx:
import { Form, redirect, useActionData, useNavigation } from "react-router";
import { createContact } from "~/data/contacts";
import type { Route } from "./+types/contacts-new";
interface ActionErrors {
firstName?: string;
lastName?: string;
email?: string;
}
export async function action({ request }: Route.ActionArgs) {
const formData = await request.formData();
const firstName = String(formData.get("firstName") || "").trim();
const lastName = String(formData.get("lastName") || "").trim();
const email = String(formData.get("email") || "").trim();
const phone = String(formData.get("phone") || "").trim();
const notes = String(formData.get("notes") || "").trim();
// التحقق على جانب الخادم
const errors: ActionErrors = {};
if (!firstName) {
errors.firstName = "الاسم الأول مطلوب";
}
if (!lastName) {
errors.lastName = "اسم العائلة مطلوب";
}
if (!email) {
errors.email = "البريد الإلكتروني مطلوب";
} else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
errors.email = "صيغة البريد الإلكتروني غير صالحة";
}
if (Object.keys(errors).length > 0) {
return { errors };
}
const contact = createContact({
firstName,
lastName,
email,
phone: phone || undefined,
notes: notes || undefined,
});
return redirect(`/contacts/${contact.id}`);
}
export default function NewContact() {
const actionData = useActionData<typeof action>();
const navigation = useNavigation();
const isSubmitting = navigation.state === "submitting";
const errors = actionData?.errors;
return (
<div className="form-page">
<h2>جهة اتصال جديدة</h2>
<Form method="post" className="contact-form">
<div className="form-group">
<label htmlFor="firstName">الاسم الأول *</label>
<input
id="firstName"
name="firstName"
type="text"
required
aria-invalid={errors?.firstName ? true : undefined}
aria-describedby={
errors?.firstName ? "firstName-error" : undefined
}
/>
{errors?.firstName && (
<p id="firstName-error" className="error-message">
{errors.firstName}
</p>
)}
</div>
<div className="form-group">
<label htmlFor="lastName">اسم العائلة *</label>
<input
id="lastName"
name="lastName"
type="text"
required
aria-invalid={errors?.lastName ? true : undefined}
/>
{errors?.lastName && (
<p className="error-message">{errors.lastName}</p>
)}
</div>
<div className="form-group">
<label htmlFor="email">البريد الإلكتروني *</label>
<input
id="email"
name="email"
type="email"
required
aria-invalid={errors?.email ? true : undefined}
/>
{errors?.email && (
<p className="error-message">{errors.email}</p>
)}
</div>
<div className="form-group">
<label htmlFor="phone">الهاتف</label>
<input id="phone" name="phone" type="tel" />
</div>
<div className="form-group">
<label htmlFor="notes">ملاحظات</label>
<textarea id="notes" name="notes" rows={3} />
</div>
<div className="form-actions">
<button type="submit" className="btn btn-primary" disabled={isSubmitting}>
{isSubmitting ? "جاري الإنشاء..." : "إنشاء جهة الاتصال"}
</button>
<a href="/" className="btn btn-secondary">
إلغاء
</a>
</div>
</Form>
</div>
);
}النقاط الرئيسية في هذا الـ action
<Form method="post">يرسل البيانات عبر طلب POST يعترضه React Routeraction()يتم تنفيذها على الخادم وتستقبل بيانات النموذج- التحقق يتم على جانب الخادم — الأخطاء تُرجع ويمكن الوصول إليها عبر
useActionData() - عند النجاح،
redirect()يعيد التوجيه إلى صفحة جهة الاتصال الجديدة useNavigation()يتيح تعطيل الزر أثناء الإرسال
الخطوة 8: صفحة تفاصيل جهة الاتصال
أنشئ app/routes/contact-detail.tsx:
import { useLoaderData, Link } from "react-router";
import { getContact } from "~/data/contacts";
import type { Route } from "./+types/contact-detail";
export async function loader({ params }: Route.LoaderArgs) {
const contact = getContact(params.contactId);
if (!contact) {
throw new Response("جهة الاتصال غير موجودة", { status: 404 });
}
return { contact };
}
export default function ContactDetail() {
const { contact } = useLoaderData<typeof loader>();
return (
<div className="detail-page">
<div className="contact-header">
<div className="contact-avatar large">
{contact.firstName[0]}
{contact.lastName[0]}
</div>
<div>
<h2>
{contact.firstName} {contact.lastName}
</h2>
<p className="contact-email">{contact.email}</p>
</div>
</div>
<div className="contact-details">
{contact.phone && (
<div className="detail-row">
<span className="label">الهاتف</span>
<span>{contact.phone}</span>
</div>
)}
{contact.notes && (
<div className="detail-row">
<span className="label">ملاحظات</span>
<p>{contact.notes}</p>
</div>
)}
<div className="detail-row">
<span className="label">تاريخ الإضافة</span>
<span>
{new Date(contact.createdAt).toLocaleDateString("ar-TN", {
year: "numeric",
month: "long",
day: "numeric",
})}
</span>
</div>
</div>
<div className="detail-actions">
<Link to={`/contacts/${contact.id}/edit`} className="btn btn-primary">
تعديل
</Link>
<Link to={`/contacts/${contact.id}/delete`} className="btn btn-danger">
حذف
</Link>
<Link to="/" className="btn btn-secondary">
رجوع
</Link>
</div>
</div>
);
}
export function ErrorBoundary() {
return (
<div className="error-page">
<h2>جهة الاتصال غير موجودة</h2>
<p>جهة الاتصال التي تبحث عنها غير موجودة أو تم حذفها.</p>
<Link to="/" className="btn btn-primary">
العودة إلى القائمة
</Link>
</div>
);
}إلقاء Response بحالة 404 في الـ loader يؤدي تلقائياً إلى تشغيل أقرب ErrorBoundary. هذا هو النمط القياسي للتعامل مع الموارد غير الموجودة في React Router v7.
الخطوة 9: التعديل مع action و loader مجتمعين
أنشئ app/routes/contact-edit.tsx:
import { Form, redirect, useLoaderData, useActionData, useNavigation } from "react-router";
import { getContact, updateContact } from "~/data/contacts";
import type { Route } from "./+types/contact-edit";
export async function loader({ params }: Route.LoaderArgs) {
const contact = getContact(params.contactId);
if (!contact) {
throw new Response("جهة الاتصال غير موجودة", { status: 404 });
}
return { contact };
}
export async function action({ request, params }: Route.ActionArgs) {
const formData = await request.formData();
const firstName = String(formData.get("firstName") || "").trim();
const lastName = String(formData.get("lastName") || "").trim();
const email = String(formData.get("email") || "").trim();
const phone = String(formData.get("phone") || "").trim();
const notes = String(formData.get("notes") || "").trim();
const errors: Record<string, string> = {};
if (!firstName) errors.firstName = "الاسم الأول مطلوب";
if (!lastName) errors.lastName = "اسم العائلة مطلوب";
if (!email) errors.email = "البريد الإلكتروني مطلوب";
if (Object.keys(errors).length > 0) {
return { errors };
}
const updated = updateContact(params.contactId, {
firstName,
lastName,
email,
phone: phone || undefined,
notes: notes || undefined,
});
if (!updated) {
throw new Response("جهة الاتصال غير موجودة", { status: 404 });
}
return redirect(`/contacts/${params.contactId}`);
}
export default function EditContact() {
const { contact } = useLoaderData<typeof loader>();
const actionData = useActionData<typeof action>();
const navigation = useNavigation();
const isSubmitting = navigation.state === "submitting";
const errors = actionData?.errors;
return (
<div className="form-page">
<h2>تعديل {contact.firstName} {contact.lastName}</h2>
<Form method="post" className="contact-form">
<div className="form-group">
<label htmlFor="firstName">الاسم الأول *</label>
<input
id="firstName"
name="firstName"
type="text"
defaultValue={contact.firstName}
required
aria-invalid={errors?.firstName ? true : undefined}
/>
{errors?.firstName && (
<p className="error-message">{errors.firstName}</p>
)}
</div>
<div className="form-group">
<label htmlFor="lastName">اسم العائلة *</label>
<input
id="lastName"
name="lastName"
type="text"
defaultValue={contact.lastName}
required
/>
{errors?.lastName && (
<p className="error-message">{errors.lastName}</p>
)}
</div>
<div className="form-group">
<label htmlFor="email">البريد الإلكتروني *</label>
<input
id="email"
name="email"
type="email"
defaultValue={contact.email}
required
/>
{errors?.email && (
<p className="error-message">{errors.email}</p>
)}
</div>
<div className="form-group">
<label htmlFor="phone">الهاتف</label>
<input
id="phone"
name="phone"
type="tel"
defaultValue={contact.phone || ""}
/>
</div>
<div className="form-group">
<label htmlFor="notes">ملاحظات</label>
<textarea
id="notes"
name="notes"
rows={3}
defaultValue={contact.notes || ""}
/>
</div>
<div className="form-actions">
<button type="submit" className="btn btn-primary" disabled={isSubmitting}>
{isSubmitting ? "جاري الحفظ..." : "حفظ التغييرات"}
</button>
<a href={`/contacts/${contact.id}`} className="btn btn-secondary">
إلغاء
</a>
</div>
</Form>
</div>
);
}وحدة المسار هذه توضح تماماً قوة React Router v7: ملف واحد يحتوي على الـ loader (تحميل البيانات) و الـ action (التعديل) و المكون (الواجهة). كل شيء موجود في مكان واحد وآمن من ناحية الأنواع.
الخطوة 10: الحذف مع تأكيد
أنشئ app/routes/contact-delete.tsx:
import { Form, redirect, useLoaderData } from "react-router";
import { getContact, deleteContact } from "~/data/contacts";
import type { Route } from "./+types/contact-delete";
export async function loader({ params }: Route.LoaderArgs) {
const contact = getContact(params.contactId);
if (!contact) {
throw new Response("جهة الاتصال غير موجودة", { status: 404 });
}
return { contact };
}
export async function action({ params }: Route.ActionArgs) {
deleteContact(params.contactId);
return redirect("/");
}
export default function DeleteContact() {
const { contact } = useLoaderData<typeof loader>();
return (
<div className="confirm-page">
<h2>تأكيد الحذف</h2>
<p>
هل أنت متأكد من حذف جهة الاتصال{" "}
<strong>
{contact.firstName} {contact.lastName}
</strong>
؟ لا يمكن التراجع عن هذا الإجراء.
</p>
<div className="confirm-actions">
<Form method="post">
<button type="submit" className="btn btn-danger">
نعم، احذف
</button>
</Form>
<a href={`/contacts/${contact.id}`} className="btn btn-secondary">
إلغاء
</a>
</div>
</div>
);
}الخطوة 11: معالجة الأخطاء الشاملة
عدّل app/root.tsx لإضافة معالجة شاملة للأخطاء:
import {
Links,
Meta,
Outlet,
Scripts,
ScrollRestoration,
isRouteErrorResponse,
} from "react-router";
import type { Route } from "./+types/root";
import "./app.css";
export function Layout({ children }: { children: React.ReactNode }) {
return (
<html lang="ar" dir="rtl">
<head>
<meta charSet="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<Meta />
<Links />
</head>
<body>
{children}
<ScrollRestoration />
<Scripts />
</body>
</html>
);
}
export default function App() {
return <Outlet />;
}
export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) {
if (isRouteErrorResponse(error)) {
return (
<div className="error-container">
<h1>
{error.status} {error.statusText}
</h1>
<p>{error.data}</p>
<a href="/">العودة إلى الصفحة الرئيسية</a>
</div>
);
}
return (
<div className="error-container">
<h1>خطأ غير متوقع</h1>
<p>حدث خطأ غير متوقع. يرجى المحاولة مرة أخرى.</p>
<a href="/">العودة إلى الصفحة الرئيسية</a>
</div>
);
}الخطوة 12: التشغيل والاختبار
شغّل خادم التطوير:
npm run devتطبيقك متاح على http://localhost:5173. اختبر الميزات:
- الصفحة الرئيسية — قائمة جهات الاتصال تُحمّل عبر الـ loader SSR
- البحث — اكتب اسماً في شريط البحث، النتائج تتحدث فوراً
- الإنشاء — انقر على "جهة اتصال جديدة"، املأ النموذج
- التحقق — أرسل نموذجاً غير مكتمل لرؤية أخطاء الخادم
- التعديل — افتح جهة اتصال وعدّل معلوماتها
- الحذف — احذف جهة اتصال من صفحة التأكيد
المفاهيم الأساسية للتذكر
Loader مقابل Action
| المفهوم | طريقة HTTP | الدور | التنفيذ |
|---|---|---|---|
loader | GET | تحميل البيانات | قبل العرض |
action | POST, PUT, DELETE | تعديل البيانات | عند إرسال النموذج |
إعادة التحقق التلقائية
بعد كل action، يقوم React Router بإعادة التحقق تلقائياً من جميع الـ loaders النشطة. هذا يعني أنه عند إنشاء جهة اتصال، تتحدث القائمة في الصفحة الرئيسية تلقائياً — بدون أي كود إضافي.
أمان الأنواع (Type Safety)
يولّد React Router v7 تلقائياً أنواعاً لكل وحدة مسار في مجلد .react-router/types/. هذا يمنحك كتابة كاملة لـ:
- معاملات المسار (
params.contactId) - وسيطات الـ loader والـ action
- البيانات المُرجعة من
useLoaderData()
استكشاف الأخطاء وإصلاحها
الأنواع لا تُولّد
شغّل الأمر التالي لإعادة توليد الأنواع:
npx react-router typegenخطأ "Cannot find module ./+types/"
تأكد أن tsconfig.json يتضمن المسارات المولّدة تلقائياً:
{
"compilerOptions": {
"rootDirs": [".", "./.react-router/types"]
}
}الـ SSR لا يعمل
تحقق من أن ssr: true محدد في react-router.config.ts وأنك تستخدم loader (وليس clientLoader).
الخطوات التالية
الآن بعد أن أتقنت أساسيات React Router v7 في وضع الإطار، إليك مسارات للتعمق أكثر:
- إضافة قاعدة بيانات: استبدل التخزين في الذاكرة بـ Prisma + PostgreSQL أو Drizzle ORM + SQLite
- المصادقة: طبّق نظام جلسات باستخدام الكوكيز
- واجهة متفائلة (Optimistic UI): استخدم
useFetcher()للتحديثات المتفائلة بدون تنقل - النشر: انشر على Vercel أو Cloudflare Workers أو خادم VPS مع Docker
- React Server Components: استكشف الدعم التجريبي لـ RSC في React Router v7
الخلاصة
يمثل React Router v7 في وضع الإطار نهجاً حديثاً وموحداً لبناء تطبيقات React full-stack. من خلال دمج الـ loaders والـ actions والـ SSR في وحدات مسار متجاورة، يبسّط التطوير بشكل كبير مع تقديم أداء ممتاز وكتابة أنواع كاملة.
المفاهيم التي تعلمتها في هذا الدرس — تحميل البيانات من الخادم، التحقق من النماذج، التعديلات، ومعالجة الأخطاء — تشكل الأساس لبناء تطبيقات ويب متينة وعالية الأداء باستخدام React.