مسیریابی برنامه‌ای با useRouter

تا اینجا هر جابه‌جایی بین صفحات با کلیک روی یک لینک (Link) انجام می‌شد. اما گاهی باید از داخل کد به صفحه‌ی دیگری برویم: مثلاً بعد از موفقیت ورود، بعد از ذخیره‌ی یک فرم، با دکمه‌ی «بازگشت» یا هنگام جست‌وجوی زنده. به این کار مسیریابی برنامه‌ای (Programmatic Navigation) می‌گویند و ابزار آن در App Router، Hook با نام useRouter است. در این درس متدهای آن را می‌شناسیم، تفاوتش را با Link و redirect می‌فهمیم و دو کامپوننت واقعی برای DevNotes می‌سازیم.

useRouter چیست؟

useRouter یک Hook است که آبجکت Router را برمی‌گرداند. با آن می‌توان بدون کلیک روی لینک، ناوبری را از کد انجام داد. نکته‌های اصلی:

  • باید از next/navigation وارد شود، نه next/router که مخصوص Pages Router است. وارد کردن از مسیر اشتباه در App Router خطا می‌دهد.
  • فقط در Client Component کار می‌کند، یعنی فایل باید با "use client" شروع شود. دلیلش این است که ناوبری یک عمل سمت مرورگر است.
  • برای ناوبری معمولی بین صفحات، خود Link بهتر است؛ useRouter برای وقتی است که ناوبری نتیجه‌ی یک رویداد یا منطق برنامه باشد.
tsx
"use client";

import { useRouter } from "next/navigation";

export default function Example() {
  const router = useRouter();
  // router.push, router.replace, router.back ...
  return null;
}

متدهای اصلی Router

متد کاربرد
router.push(href) رفتن به یک آدرس و افزودن یک رکورد جدید به تاریخچه‌ی مرورگر؛ دکمه‌ی «بازگشت» به صفحه‌ی قبل برمی‌گردد.
router.replace(href) رفتن به یک آدرس با جایگزین‌کردن رکورد فعلی تاریخچه؛ «بازگشت» به آدرس قبلی نمی‌رود.
router.back() یک قدم به عقب در تاریخچه (مثل دکمه‌ی «بازگشت» مرورگر).
router.forward() یک قدم به جلو در تاریخچه.
router.refresh() درخواست دوباره‌ی داده‌ی مسیر فعلی از سرور و به‌روزرسانی Server Componentها، بدون از دست رفتن State کامپوننت‌های کلاینتی.
router.prefetch(href) بارگیری پیشاپیش یک مسیر برای ناوبری سریع‌تر (کاربرد محدود؛ جزئیات در درس «Prefetching و رفتار Navigation»).

push در برابر replace

تفاوت این دو در تاریخچه‌ی مرورگر است. فرض کنید کاربر از /posts به /login رفته و پس از ورود به /dashboard فرستاده می‌شود:

  • با push، تاریخچه چنین می‌شود: /posts ← /login ← /dashboard. با «بازگشت» دوباره صفحه‌ی ورود دیده می‌شود، که معمولاً مطلوب نیست.
  • با replace، رکورد /login با /dashboard جایگزین می‌شود: /posts ← /dashboard.

قاعده‌ی عملی: جابه‌جایی‌های عادی با push، و جابه‌جایی‌هایی که نباید در تاریخچه بمانند (بعد از ورود، یا به‌روزرسانی پیاپی Query String هنگام تایپ) با replace.

گزینه‌ی scroll

push و replace یک آرگومان دوم اختیاری دارند. به‌طور پیش‌فرض بعد از ناوبری به بالای صفحه می‌رویم. با scroll: false می‌توان آن را خاموش کرد:

tsx
router.push("/posts?page=2", { scroll: false });

سه راه رفتن به صفحه‌ی دیگر: کدام را کِی؟

ابزار کجا کار می‌کند مناسب برای
Link Server و Client Component لینک‌هایی که کاربر روی آن‌ها کلیک می‌کند (ناوبری، منو، فهرست)
useRouter فقط Client Component ناوبری بعد از رویداد یا منطق سمت مرورگر (بازگشت، جست‌وجوی زنده، بعد از پاسخ موفق)
redirect() Server Component، Server Action و Route Handler هدایت در سمت سرور، قبل از ارسال صفحه (در درس «Redirect، not-found.tsx و notFound()»)

اگر می‌توانید با یک Link کار را انجام دهید، همان بهتر است: یک تگ <a> واقعی می‌سازد، برای SEO و دسترس‌پذیری خوب است و با کلیک میانی و «باز کردن در تب جدید» هم درست کار می‌کند. router.push هیچ‌کدام از این‌ها را ندارد.

عملی ۱: دکمه‌ی بازگشت

یک کامپوننت کوچک می‌سازیم که با router.back() به صفحه‌ی قبلی برمی‌گردد. چون Hook لازم دارد، Client Component است.

tsx
// components/BackButton.tsx
"use client";

import { useRouter } from "next/navigation";

type BackButtonProps = {
  label?: string;
};

export default function BackButton({ label = "بازگشت" }: BackButtonProps) {
  const router = useRouter();

  return (
    <button type="button" onClick={() => router.back()}>
      {label}
    </button>
  );
}
  • type="button" باعث می‌شود اگر داخل یک فرم قرار گرفت، فرم را ارسال نکند.
  • label یک prop اختیاری با مقدار پیش‌فرض است تا بتوان متن را عوض کرد.

حالا آن را در صفحه‌ی جزئیات نوشته استفاده می‌کنیم. چون صفحه Server Component است و فقط BackButton در مرورگر اجرا می‌شود، این ترکیب کاملاً مجاز است (جزئیات در فصل «Server Components و Client Components»).

tsx
// app/(public)/posts/[slug]/page.tsx
import BackButton from "@/components/BackButton";

type PostPageProps = {
  params: Promise<{ slug: string }>;
};

export async function generateStaticParams() {
  return [
    { slug: "hello-nextjs" },
    { slug: "app-router-intro" },
  ];
}

export default async function PostPage({ params }: PostPageProps) {
  const { slug } = await params;

  return (
    <main>
      <BackButton />
      <h1>نوشته: {slug}</h1>
      <p>محتوای این نوشته بعد از اتصال به داده نمایش داده می‌شود.</p>
    </main>
  );
}

اگر کاربر مستقیم با وارد کردن آدرس وارد این صفحه شده باشد و تاریخچه‌ای نداشته باشد، back() او را از سایت بیرون می‌برد یا کاری نمی‌کند. برای جایی که مقصد بازگشت باید قطعی باشد، Link مطمئن‌تر است.

عملی ۲: جعبه‌ی جست‌وجو با useRouter

در درس «searchParams و Query String» جست‌وجو را با یک فرم GET ساده ساختیم. اینجا همان را با router.push می‌سازیم تا الگوی ناوبری از داخل کد را ببینید. این روش در جست‌وجوی زنده و فرم‌های پیچیده‌تر کاربرد دارد.

فایل‌های متأثر: فایل جدید components/SearchBox.tsx و تغییر app/(public)/posts/page.tsx (فرم قبلی با این کامپوننت جایگزین می‌شود).

tsx
// components/SearchBox.tsx
"use client";

import { useState, type FormEvent } from "react";
import { useRouter } from "next/navigation";

type SearchBoxProps = {
  defaultValue?: string;
};

export default function SearchBox({ defaultValue = "" }: SearchBoxProps) {
  const router = useRouter();
  const [value, setValue] = useState(defaultValue);

  function handleSubmit(event: FormEvent<HTMLFormElement>) {
    event.preventDefault();

    const q = value.trim();
    const search = new URLSearchParams();
    if (q) search.set("q", q);

    const queryString = search.toString();
    router.push(queryString ? `/posts?${queryString}` : "/posts");
  }

  return (
    <form role="search" onSubmit={handleSubmit}>
      <label htmlFor="q">جست‌وجو در نوشته‌ها</label>
      <input
        id="q"
        type="search"
        value={value}
        onChange={(event) => setValue(event.target.value)}
      />
      <button type="submit">بجو</button>
    </form>
  );
}
tsx
// app/(public)/posts/page.tsx
import Link from "next/link";
import PostsIntro from "./_components/PostsIntro";
import SearchBox from "@/components/SearchBox";

type PostsPageProps = {
  searchParams: Promise<{
    [key: string]: string | string[] | undefined;
  }>;
};

function first(value: string | string[] | undefined): string | undefined {
  return Array.isArray(value) ? value[0] : value;
}

function buildHref(q: string, page: number): string {
  const search = new URLSearchParams();
  if (q) search.set("q", q);
  if (page > 1) search.set("page", String(page));
  const queryString = search.toString();
  return queryString ? `/posts?${queryString}` : "/posts";
}

export default async function PostsPage({ searchParams }: PostsPageProps) {
  const params = await searchParams;

  const q = first(params.q)?.trim() ?? "";

  const rawPage = Number(first(params.page));
  const page = Number.isInteger(rawPage) && rawPage >= 1 ? rawPage : 1;

  return (
    <main>
      <h1>نوشته‌ها</h1>
      <PostsIntro />

      <SearchBox key={q} defaultValue={q} />

      <p>
        {q ? `نتیجه‌ی جست‌وجو برای «${q}»` : "همه‌ی نوشته‌ها"} — صفحه‌ی {page}
      </p>

      <nav aria-label="صفحه‌بندی">
        {page > 1 && (
          <Link href={buildHref(q, page - 1)}>صفحه‌ی قبل</Link>
        )}{" "}
        <Link href={buildHref(q, page + 1)}>صفحه‌ی بعد</Link>
      </nav>
    </main>
  );
}

توضیح کد

  • "use client" در SearchBox لازم است چون هم useState و هم useRouter دارد.
  • event.preventDefault() جلوی ارسال معمولی فرم و بارگذاری کامل صفحه را می‌گیرد تا خودمان ناوبری را انجام دهیم.
  • router.push آدرس را عوض می‌کند. چون صفحه‌ی /posts searchParams را می‌خواند، Next.js آن را با Query String جدید دوباره رندر می‌کند.
  • صفحه‌ی PostsPage همچنان Server Component است؛ مقدار q را از searchParams می‌خواند و از طریق prop به SearchBox می‌دهد. به این ترتیب لازم نیست در Client Component از useSearchParams استفاده کنیم.
  • key={q} یک نکته‌ی مهم دارد: useState(defaultValue) فقط مقدار اولیه را می‌گیرد. اگر کاربر با دکمه‌ی «بازگشت» مرورگر آدرس را عوض کند، defaultValue تغییر می‌کند ولی State قدیمی می‌ماند. با دادن key، هر بار که q عوض شود، React کامپوننت را از نو می‌سازد و مقدار فیلد با آدرس هماهنگ می‌شود.
  • این فرم بدون JavaScript کار نمی‌کند، در حالی که فرم GET قبلی بدون JavaScript هم کار می‌کرد. برای جست‌وجوی ساده، فرم GET ساده‌تر و مقاوم‌تر است؛ ما اینجا برای آموزش از useRouter استفاده کردیم.

خروجی و رفتار مورد انتظار

  • در /posts عبارتی بنویسید و دکمه را بزنید: آدرس به /posts?q=... تغییر می‌کند و متن نتیجه به‌روز می‌شود، بدون بارگذاری کامل صفحه.
  • فیلد را خالی بفرستید: آدرس به /posts برمی‌گردد.
  • بعد از چند جست‌وجو، دکمه‌ی «بازگشت» مرورگر را بزنید: آدرس و مقدار داخل فیلد هر دو به حالت قبل برمی‌گردند.
  • در صفحه‌ی /posts/hello-nextjs دکمه‌ی «بازگشت» شما را به صفحه‌ی قبلی می‌برد.

جست‌وجوی زنده با replace (فقط برای شناخت)

اگر بخواهیم با هر بار تایپ، نتیجه به‌روز شود، نباید با هر کاراکتر یک رکورد تاریخچه ساخته شود. در این حالت از replace و یک تأخیر کوتاه (Debounce) استفاده می‌کنند:

tsx
// نمونه‌ی مفهومی؛ در DevNotes اعمال نمی‌شود
useEffect(() => {
  const timer = setTimeout(() => {
    const q = value.trim();
    router.replace(q ? `/posts?q=${encodeURIComponent(q)}` : "/posts", {
      scroll: false,
    });
  }, 400);

  return () => clearTimeout(timer);
}, [value, router]);
  • setTimeout تا ۴۰۰ میلی‌ثانیه بعد از آخرین تایپ صبر می‌کند و clearTimeout تایمر قبلی را لغو می‌کند.
  • replace مانع شلوغ‌شدن تاریخچه می‌شود.
  • scroll: false صفحه را هنگام تایپ به بالا نمی‌برد.

router.refresh چه کاری می‌کند؟

router.refresh() مسیر فعلی را دوباره از سرور می‌گیرد و Server Componentهای آن را به‌روز می‌کند، در حالی که State کامپوننت‌های کلاینتی (مثلاً متن داخل یک فیلد) حفظ می‌شود. ممکن است فکر کنید این همان بازاعتبارسنجی کش است، ولی این دو یکی نیستند: refresh سمت کلاینت فقط Router را تازه می‌کند و داده‌ی کش‌شده‌ی سرور را لزوماً منقضی نمی‌کند. تفاوت دقیق آن با revalidatePath، revalidateTag و refresh سمت سرور را در درس «refresh، به‌روزرسانی رابط کاربری و تفاوت آن با Revalidation» می‌بینیم.

نکته‌های مهم

  • import درست: useRouter را از next/navigation بگیرید. نسخه‌ی next/router فقط در Pages Router کار می‌کند و در App Router خطا می‌دهد.
  • Router رویداد ندارد: آبجکت Router در App Router رویدادهایی مثل router.events ندارد. برای واکنش به تغییر مسیر، از usePathname و useSearchParams استفاده می‌شود.
  • آدرس را اعتبارسنجی کنید: اگر مقصد ناوبری از ورودی کاربر یا Query String می‌آید (مثلاً ?next=/dashboard)، قبل از استفاده بررسی کنید که فقط آدرس داخلی سایت باشد، وگرنه کاربر می‌تواند به سایت دیگری هدایت شود. این موضوع را در فصل «Authentication و Security» دوباره می‌بینیم.
  • Router ناوبری سرور را جایگزین نمی‌کند: هدایت‌هایی که باید همیشه رخ دهند (مثل «اگر وارد نشده‌ای برو به ورود») در سمت سرور انجام می‌شوند، نه با useRouter؛ چون کد کلاینت قابل دور زدن است.
  • برای لینک‌های معمولی Link: اگر برای یک منو از onClick و router.push استفاده کنید، دسترس‌پذیری و رفتار مرورگر (مثل کلیک راست و باز کردن در تب جدید) از دست می‌رود.

جمع‌بندی

  • useRouter از next/navigation برای ناوبری از داخل کد است و فقط در Client Component کار می‌کند.
  • متدهای اصلی: push (رکورد جدید)، replace (جایگزینی رکورد)، back، forward، refresh و prefetch.
  • برای کلیک کاربر روی لینک Link، برای ناوبری بعد از رویداد useRouter و برای هدایت سمت سرور redirect() مناسب است.
  • برای هماهنگ‌ماندن State کامپوننت با آدرس می‌توان از key استفاده کرد.
  • در DevNotes دو کامپوننت BackButton و SearchBox ساختیم و صفحه‌ی نوشته‌ها و جزئیات نوشته را به‌روز کردیم.
  • در درس بعد، «Redirect، not-found.tsx و notFound()»، هدایت و مدیریت صفحه‌ی پیدا نشد را در سمت سرور یاد می‌گیریم.