State، Event و Hookها

در درس «Client Components و دستور use client» دیدیم که هر کامپوننتی که به تعامل با کاربر نیاز دارد باید Client Component باشد. اما این تعامل از چه چیزهایی ساخته می‌شود؟ جواب سه ابزار اصلی React است: State (حافظه‌ی کامپوننت)، Event (واکنش به کار کاربر) و Hookها (توابعی که این قابلیت‌ها را در اختیار کامپوننت می‌گذارند). با این‌ها در دوره‌ی React آشنا شده‌اید؛ اینجا آن‌ها را از نگاه Next.js مرور می‌کنیم: کجا مجازند، چه رفتاری روی سرور و مرورگر دارند و چطور کنار Server Componentها قرار می‌گیرند. در پایان دو کامپوننت واقعی می‌سازیم: یک دکمه‌ی «پسندیدن» برای تمرین و یک نوار «پیشرفت مطالعه» که در DevNotes می‌ماند.

Hookها فقط در Client Component

Hookهای تعاملی React، مثل useState و useEffect، فقط در Client Component کار می‌کنند. دلیلش این است که Server Component فقط یک‌بار اجرا می‌شود و نتیجه‌اش به مرورگر می‌رود؛ جایی برای نگه‌داشتن State بین رندرها یا اجرای Effect در مرورگر ندارد. وقتی فایل بدون "use client" چنین Hookی را صدا بزند، Next.js در حالت توسعه خطا می‌دهد و راهنمایی می‌کند.

State با useState

State مقداری است که کامپوننت آن را بین رندرها به خاطر می‌سپارد و تغییرش باعث رندر دوباره‌ی کامپوننت می‌شود. Hook آن useState است و آرایه‌ای دو‌عضوی برمی‌گرداند: مقدار فعلی و تابع تغییر آن.

tsx
"use client";

import { useState } from "react";

export default function Counter() {
  const [count, setCount] = useState(0);

  return (
    <button type="button" onClick={() => setCount(count + 1)}>
      {count}
    </button>
  );
}

نکته‌های اساسی State

  • مقدار اولیه فقط یک‌بار استفاده می‌شود. در useState(0) عدد صفر فقط در اولین رندر خوانده می‌شود. اگر Propی که به‌عنوان مقدار اولیه داده‌اید بعداً عوض شود، State تغییر نمی‌کند. همین موضوع را در SearchBox دیدیم و با key حلش کردیم (درس «مسیریابی برنامه‌ای با useRouter»).
  • تغییر State فوری نیست. بعد از setCount(count + 1)، متغیر count در همان اجرای تابع هنوز مقدار قبلی را دارد. مقدار جدید در رندر بعدی دیده می‌شود.
  • وقتی مقدار جدید به قبلی وابسته است، تابع بدهید: setCount((prev) => prev + 1) همیشه با آخرین مقدار کار می‌کند و در به‌روزرسانی‌های پشت سر هم هم درست است.
  • آبجکت و آرایه را تغییر ندهید، کپی بسازید: برای مثال setForm({ ...form, title: "جدید" }) به‌جای تغییر مستقیم form.title. React فقط تغییر «مرجع جدید» را تشخیص می‌دهد.
  • State مال همان نمونه‌ی کامپوننت است. دو Counter در یک صفحه هرکدام State جداگانه دارند. با Unmount شدن کامپوننت (مثلاً جابه‌جایی به صفحه‌ی دیگر که آن را ندارد) State از بین می‌رود.
  • State را از Server هم می‌توان مقداردهی کرد: Server Component می‌تواند مقدار اولیه را به‌صورت Prop بدهد؛ همان الگوی defaultValue در SearchBox.

رویدادها (Events)

رویدادهای React مثل رویدادهای DOM هستند، اما با نام camelCase نوشته می‌شوند: onClick، onChange، onSubmit، onKeyDown و مانند آن. مقدار آن‌ها باید یک تابع باشد، نه نتیجه‌ی صدا زدن تابع:

tsx
// درست: تابع به React داده می‌شود تا هنگام کلیک صدا بزند
<button type="button" onClick={handleClick}>ثبت</button>
<button type="button" onClick={() => handleDelete(id)}>حذف</button>

// نادرست: handleClick() همان لحظه‌ی رندر اجرا می‌شود
<button type="button" onClick={handleClick()}>ثبت</button>

نوع رویدادها در TypeScript

رویداد نوع مثال
کلیک MouseEvent<HTMLButtonElement> onClick روی دکمه
تغییر مقدار فیلد ChangeEvent<HTMLInputElement> onChange روی ورودی
ارسال فرم FormEvent<HTMLFormElement> onSubmit روی فرم

اگر تابع رویداد را همان‌جا (inline) بنویسید، TypeScript نوع را خودش حدس می‌زند و لازم نیست چیزی بنویسید. فقط وقتی تابع را جدا تعریف می‌کنید نوع پارامتر را می‌نویسید، همان‌طور که در SearchBox برای handleSubmit کردیم.

الگوی «ورودی کنترل‌شده» ترکیب State و رویداد است: مقدار فیلد در State نگه داشته می‌شود و onChange آن را به‌روز می‌کند:

tsx
"use client";

import { useState, type ChangeEvent } from "react";

export default function TitleField() {
  const [title, setTitle] = useState("");

  function handleChange(event: ChangeEvent<HTMLInputElement>) {
    setTitle(event.target.value);
  }

  return (
    <label>
      عنوان
      <input value={title} onChange={handleChange} />
      <small>{title.length} نویسه</small>
    </label>
  );
}

رویدادها فقط روی مرورگر کار می‌کنند و بعد از Hydration فعال می‌شوند. قبل از آن، دکمه‌ها در HTML هستند ولی هنوز کلیکشان کاری نمی‌کند. در شبکه‌ی کند ممکن است کاربر لحظه‌ای کلیک کند و واکنشی نبیند؛ این یکی از دلایلی است که مرز Client را کوچک نگه می‌داریم.

Effect با useEffect

useEffect برای همگام‌سازی کامپوننت با یک سیستم بیرونی است: چیزی خارج از React، مثل رویداد اسکرول پنجره، تایمر، اتصال شبکه یا یک کتابخانه‌ی مرورگر. ویژگی‌های آن:

  • بعد از رندر و فقط در مرورگر اجرا می‌شود؛ هرگز در زمان رندر روی سرور اجرا نمی‌شود. به همین دلیل جای مناسبی برای کدی است که به window یا document نیاز دارد.
  • آرایه‌ی وابستگی (آرگومان دوم) تعیین می‌کند چه زمانی دوباره اجرا شود: [] یعنی فقط بعد از اولین رندر؛ [a, b] یعنی هر وقت a یا b تغییر کنند؛ بدون آرایه یعنی بعد از هر رندر.
  • تابع می‌تواند یک تابع پاکسازی (Cleanup) برگرداند. React آن را قبل از اجرای دوباره‌ی Effect و هنگام حذف کامپوننت صدا می‌زند.
  • در حالت توسعه و Strict Mode، React عمداً Effect را یک‌بار اضافه اجرا و پاک می‌کند (mount، cleanup، mount) تا اگر cleanup نوشته نشده باشد مشکل را نشان بدهد. این رفتار در Production وجود ندارد.
tsx
useEffect(() => {
  function handleResize() {
    // ...
  }

  window.addEventListener("resize", handleResize);

  // پاکسازی: شنونده‌ی قبلی را برمی‌دارد
  return () => window.removeEventListener("resize", handleResize);
}, []);

چه چیزهایی نیاز به Effect ندارند؟

کار روش درست
واکنش به کلیک یا ارسال فرم تابع رویداد (onClick، onSubmit)
محاسبه‌ی مقداری از State یا Props یک متغیر معمولی در بدنه‌ی رندر
گرفتن داده برای نمایش اولیه معمولاً Server Component؛ در Client هم روش‌های بهتری هست (درس «دریافت داده در Client Components و موارد استفاده»)
ریست State وقتی Prop عوض می‌شود دادن key به کامپوننت

قاعده‌ی سرانگشتی: اگر کاری نتیجه‌ی مستقیم «کاربر یک کار کرد» است، آن را در تابع رویداد بنویسید، نه در Effect. Effect برای «کامپوننت باید با دنیای بیرون هماهنگ بماند» است.

سایر Hookهای پرکاربرد

Hook کاربرد محل بررسی در دوره
useRef دسترسی به عنصر DOM یا نگه‌داشتن مقداری که تغییرش رندر دوباره ایجاد نکند در حد نیاز
useId ساخت شناسه‌ی یکتا برای ارتباط label و input، با خروجی یکسان در سرور و مرورگر فصل «معماری، عملکرد، دسترس‌پذیری و تست»
useMemo و useCallback ذخیره‌ی نتیجه‌ی محاسبه یا تابع بین رندرها درس «React Compiler در حد کاربردی و متناسب با نسخه»
useActionState، useFormStatus، useOptimistic کار با فرم‌ها و Server Actions فصل «فرم‌ها و Server Actions»
useRouter، usePathname، useSearchParams Hookهای مخصوص Next.js برای مسیر و آدرس (از next/navigation) در درس‌های قبلی استفاده کردیم

قواعد Hookها

  • Hookها را فقط در سطح بالای کامپوننت (یا Hook دلخواه) صدا بزنید، نه داخل شرط، حلقه یا تابع‌های تودرتو.
  • Hookها را فقط داخل کامپوننت‌های تابعی React یا Hookهای دلخواه صدا بزنید.
  • نام Hook دلخواه باید با use شروع شود.
  • ESLint در پروژه‌ی Next.js این قواعد را بررسی می‌کند و هشدار می‌دهد.

عملی ۱: دکمه‌ی «پسندیدن» (تمرین State و رویداد)

این یک تمرین کوتاه است و بعد از آزمایش حذفش می‌کنیم، چون «پسندیدن» واقعی به پایگاه داده و کاربر وارد‌شده نیاز دارد که هنوز نداریم.

فایل جدید: components/LikeButton.tsx

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

import { useState } from "react";

type LikeButtonProps = {
  initialCount?: number;
};

export default function LikeButton({ initialCount = 0 }: LikeButtonProps) {
  const [count, setCount] = useState(initialCount);
  const [liked, setLiked] = useState(false);

  function handleClick() {
    const nextLiked = !liked;
    setLiked(nextLiked);
    setCount((prev) => prev + (nextLiked ? 1 : -1));
  }

  return (
    <button type="button" aria-pressed={liked} onClick={handleClick}>
      {liked ? "پسندیده شد" : "پسندیدن"} ({count})
    </button>
  );
}

توضیح کد

  • دو State مستقل داریم: count (تعداد) و liked (آیا این کاربر پسندیده است).
  • nextLiked مقدار بعدی را از روی مقدار فعلی حساب می‌کند. چون setLiked فوراً liked را عوض نمی‌کند، برای محاسبه‌ی count از nextLiked استفاده می‌کنیم، نه liked.
  • setCount((prev) => ...) از شکل تابعی استفاده می‌کند تا همیشه روی آخرین مقدار کار کند.
  • aria-pressed وضعیت روشن/خاموش دکمه را به صفحه‌خوان‌ها می‌گوید.
  • initialCount فقط مقدار اولیه است؛ بعد از رندر اول، State مستقل می‌شود.

آن را موقتاً در صفحه‌ی جزئیات نوشته کنار CopyLinkButton بگذارید (<LikeButton /> با import از @/components/LikeButton)، امتحانش کنید و بعد هم کامپوننت و هم import را پاک کنید. رفتار مورد انتظار: هر کلیک بین «پسندیدن (0)» و «پسندیده شد (1)» جابه‌جا می‌شود. با رفرش صفحه، State به صفر برمی‌گردد، چون در هیچ‌جا ذخیره نشده است؛ State کامپوننت فقط در حافظه‌ی همان صفحه است.

عملی ۲: نوار پیشرفت مطالعه (تمرین Effect)

این یکی در DevNotes می‌ماند: نواری که نشان می‌دهد کاربر چند درصد از صفحه‌ی نوشته را اسکرول کرده است. اسکرول پنجره یک «سیستم بیرونی» است، پس useEffect با cleanup مناسب است.

فایل‌های متأثر: components/ReadingProgress.tsx (جدید) و app/(public)/posts/[slug]/page.tsx (یک خط اضافه).

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

import { useEffect, useState } from "react";

export default function ReadingProgress() {
  const [progress, setProgress] = useState(0);

  useEffect(() => {
    function handleScroll() {
      const { scrollTop, scrollHeight, clientHeight } =
        document.documentElement;
      const maxScroll = scrollHeight - clientHeight;

      if (maxScroll <= 0) {
        setProgress(0);
        return;
      }

      setProgress(Math.min(100, Math.round((scrollTop / maxScroll) * 100)));
    }

    window.addEventListener("scroll", handleScroll, { passive: true });

    return () => window.removeEventListener("scroll", handleScroll);
  }, []);

  return (
    <progress
      value={progress}
      max={100}
      aria-label="میزان پیشرفت مطالعه"
    />
  );
}

توضیح کد

  • مقدار اولیه‌ی State صفر است؛ سرور و مرورگر در اولین رندر خروجی یکسان تولید می‌کنند و مشکلی در Hydration پیش نمی‌آید.
  • دسترسی به document و window فقط داخل useEffect و تابع handleScroll است که روی سرور اجرا نمی‌شوند.
  • setProgress داخل تابع رویداد صدا زده می‌شود، یعنی وقتی واقعاً اسکرول رخ داده است.
  • آرایه‌ی وابستگی خالی است، چون شنونده فقط یک‌بار لازم است نصب شود.
  • تابع برگشتی شنونده را برمی‌دارد. بدون آن، با هر رفت‌وبرگشت بین صفحات، یک شنونده‌ی اضافه روی window باقی می‌ماند. در حالت توسعه، رفتار دوباره‌اجرای Strict Mode دقیقاً همین خطا را آشکار می‌کند.
  • { passive: true } به مرورگر می‌گوید این شنونده جلوی اسکرول را نمی‌گیرد، پس مرورگر می‌تواند اسکرول را روان‌تر انجام دهد.
  • عنصر progress یک تگ HTML معنایی است و با aria-label برای صفحه‌خوان‌ها توضیح داده شده است.

استفاده در صفحه‌ی جزئیات

فقط یک import و یک خط به app/(public)/posts/[slug]/page.tsx اضافه می‌کنیم. بقیه‌ی فایل بدون تغییر می‌ماند:

tsx
// app/(public)/posts/[slug]/page.tsx (بخش‌های تغییرکرده)
import ReadingProgress from "@/components/ReadingProgress";

// ... داخل خروجی PostPage:
return (
  <main>
    <ReadingProgress />
    <BackButton />
    <article>
      {/* ... بدون تغییر */}
    </article>
    {/* ... */}
  </main>
);

صفحه‌ی PostPage همچنان Server Component است؛ سه کامپوننت ReadingProgress، BackButton و CopyLinkButton فقط بخش‌های کوچک تعاملی هستند.

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

  • چون متن نوشته‌های نمونه کوتاه است، صفحه معمولاً اسکرول ندارد و نوار روی ۰٪ می‌ماند.
  • برای دیدن اثر، پنجره‌ی مرورگر را کوتاه کنید یا در ابزار توسعه‌دهنده یک div بلند موقت به صفحه اضافه کنید؛ با اسکرول، نوار پر می‌شود.
  • در کنسول مرورگر هیچ هشدار یا خطایی نباید ببینید. اگر ESLint هشدار داد، پیام آن را بخوانید؛ معمولاً درباره‌ی وابستگی‌های Effect یا قواعد Hookها است.

نکته‌های مهم

  • State را فقط وقتی لازم است بسازید. مقداری که از Props یا State دیگر قابل محاسبه است، State جدا نمی‌خواهد؛ آن را در بدنه‌ی رندر حساب کنید.
  • به ترتیب و تعداد Hookها دست نزنید. صدا زدن Hook داخل if یا بعد از return زودهنگام قواعد را می‌شکند.
  • برای Effect همیشه به cleanup فکر کنید. هر چیزی که در Effect «راه می‌اندازید» (شنونده، تایمر، اتصال) باید پاک شود.
  • رندر باید خالص باشد. داخل بدنه‌ی رندر از Math.random()، Date.now() یا خواندن window استفاده نکنید؛ هم React رندر را ممکن است چندبار اجرا کند و هم خروجی سرور و مرورگر ناهمخوان می‌شود.
  • State سمت کلاینت ذخیره‌ی دائمی نیست. با رفرش یا بستن تب از بین می‌رود. داده‌ای که باید بماند به پایگاه داده نیاز دارد (فصل «پایگاه داده با Prisma و SQLite»).
  • به‌جای Client کردن کل صفحه، Hook را در کوچک‌ترین کامپوننت بگذارید. همین الگو در ساخت ReadingProgress دیده می‌شود.

جمع‌بندی

  • State حافظه‌ی کامپوننت است (useState)، تغییرش رندر دوباره ایجاد می‌کند و باید بدون تغییر مستقیم (با کپی) به‌روز شود؛ مقدار اولیه‌اش فقط یک‌بار استفاده می‌شود.
  • رویدادهای React با نام camelCase نوشته می‌شوند و مقدارشان باید تابع باشد؛ نوع آن‌ها در TypeScript با ChangeEvent، FormEvent و مشابه آن‌ها مشخص می‌شود.
  • useEffect برای هماهنگی با سیستم‌های بیرونی است، فقط در مرورگر اجرا می‌شود و باید در صورت نیاز cleanup داشته باشد؛ کارهای ناشی از رویداد کاربر را در Effect ننویسید.
  • Hookها فقط در Client Component و طبق قواعد Hookها (سطح بالا، بدون شرط) کار می‌کنند.
  • در DevNotes اکنون ReadingProgress به‌عنوان یک Client Component کوچک به صفحه‌ی نوشته اضافه شده است و LikeButton فقط تمرین بود و حذف می‌شود.
  • در درس بعد، «Props، انتقال داده و مرز Server/Client»، می‌بینیم چه داده‌هایی را می‌توان از Server Component به Client Component داد و چه چیزهایی مجاز نیستند.