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 است و آرایهای دوعضوی برمیگرداند: مقدار فعلی و تابع تغییر آن.
"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 و مانند آن. مقدار آنها باید یک تابع باشد، نه نتیجهی صدا زدن تابع:
// درست: تابع به 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 آن را بهروز میکند:
"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 وجود ندارد.
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
// 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 (یک خط اضافه).
// 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 اضافه میکنیم. بقیهی فایل بدون تغییر میماند:
// 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 داد و چه چیزهایی مجاز نیستند.
