مقالات › React

۱۰ اشتباه رایج در useEffect و راه‌حل هرکدام

رایج‌ترین باگ‌های useEffect در React — حلقه‌ی بی‌نهایت، Closure کهنه، Race Condition، Cleanup فراموش‌شده و Effectهای غیرضروری — به‌همراه راه‌حل و جایگزین‌های بهتر.

useEffect پرسوءاستفاده‌ترین هوک React است. بسیاری از باگ‌ها — حلقه‌ی بی‌نهایت، پرش رابط کاربری، درخواست‌های تکراری و نشت حافظه — از Effectهایی می‌آیند که یا نباید وجود داشته باشند یا اشتباه نوشته شده‌اند. در این مقاله ده اشتباه واقعی و راه‌حل هرکدام را مرور می‌کنیم.

اول مدل ذهنی درست: یک Effect کامپوننت شما را با یک سیستم بیرونی هماهنگ می‌کند. اگر سیستم بیرونی‌ای در کار نیست (شبکه، API مرورگر، تایمر، Subscription، کتابخانه‌ی جانبی)، احتمالاً به Effect نیازی ندارید.

۱. محاسبه‌ی State داخل Effect

// ❌ رندر اضافه، State اضافه و احتمال ناهماهنگی
const [fullName, setFullName] = useState("");
useEffect(() => {
  setFullName(`${firstName} ${lastName}`);
}, [firstName, lastName]);
// ✅ در زمان رندر محاسبه کنید
const fullName = `${firstName} ${lastName}`;

اگر محاسبه سنگین است، از useMemo استفاده کنید؛ باز هم نیازی به Effect نیست.

۲. حلقه‌ی بی‌نهایت

// ❌ Stateی را تغییر می‌دهد که خودش وابستگی است → تا ابد اجرا می‌شود
useEffect(() => {
  setCount(count + 1);
}, [count]);

Effectی که وابستگی خودش را تغییر دهد، دوباره خودش را اجرا می‌کند. بپرسید چرا این State باید تغییر کند. معمولاً جایش در Event Handler است.

۳. شیء و تابع به‌عنوان وابستگی

// ❌ options در هر رندر یک شیء جدید است → Effect در هر رندر اجرا می‌شود
const options = { includeArchived: false };
useEffect(() => {
  fetchItems(options);
}, [options]);

راه‌حل‌ها به ترتیب اولویت:

// ✅ شیء را داخل Effect ببرید
useEffect(() => {
  const options = { includeArchived: false };
  fetchItems(options);
}, []);

// ✅ به مقادیر ساده (Primitive) وابسته باشید
useEffect(() => {
  fetchItems({ includeArchived });
}, [includeArchived]);

// ✅ اگر باید مشترک باشد، Memo کنید
const options = useMemo(() => ({ includeArchived }), [includeArchived]);

۴. دروغ گفتن درباره‌ی وابستگی‌ها

// ❌ از userId استفاده می‌کند اما آن را نمی‌آورد — همیشه داده‌ی کاربر اول نمایش داده می‌شود
useEffect(() => {
  loadProfile(userId);
  // eslint-disable-next-line react-hooks/exhaustive-deps
}, []);

غیرفعال کردن Linter یک باگ واقعی را پنهان می‌کند. هر مقدار واکنشی که Effect می‌خواند را بیاورید. اگر این کار باعث اجرای زیاد شد، ساختار کد را عوض کنید؛ به React دروغ نگویید.

۵. Race Condition هنگام دریافت داده

وقتی userId سریع تغییر می‌کند، پاسخ‌ها ممکن است نامرتب برسند و پاسخ قدیمی روی پاسخ جدید بنشیند.

// ✅ نادیده گرفتن پاسخ‌های قدیمی با AbortController
useEffect(() => {
  const controller = new AbortController();

  fetch(`/api/users/${userId}`, { signal: controller.signal })
    .then((r) => r.json())
    .then(setUser)
    .catch((err) => {
      if (err.name !== "AbortError") setError(err);
    });

  return () => controller.abort();
}, [userId]);

Cleanup هر بار که userId تغییر کند یا کامپوننت Unmount شود، درخواست قبلی را لغو می‌کند.

۶. فراموش کردن Cleanup

هر Subscription یک لغو Subscription لازم دارد:

useEffect(() => {
  const onResize = () => setWidth(window.innerWidth);
  window.addEventListener("resize", onResize);
  return () => window.removeEventListener("resize", onResize);
}, []);

useEffect(() => {
  const id = setInterval(tick, 1000);
  return () => clearInterval(id);
}, []);

بدون Cleanup، Listenerها نشت می‌کنند، تایمرها بعد از رفتن به صفحه‌ی دیگر هم اجرا می‌شوند و State کامپوننت‌های Unmountشده به‌روزرسانی می‌شود.

۷. Closure کهنه در Interval

// ❌ count داخل Interval روی صفر ثابت مانده
useEffect(() => {
  const id = setInterval(() => setCount(count + 1), 1000);
  return () => clearInterval(id);
}, []);
// ✅ تابع به‌روزرسان همیشه آخرین مقدار را دریافت می‌کند
useEffect(() => {
  const id = setInterval(() => setCount((c) => c + 1), 1000);
  return () => clearInterval(id);
}, []);

برای Callbackهایی که به آخرین Props نیاز دارند بدون اینکه Effect دوباره شروع شود، آن‌ها را در یک Ref نگه دارید (یا در صورت وجود از useEffectEvent استفاده کنید).

۸. استفاده از Effect برای رویدادهای کاربر

// ❌ ارسال فرم از طریق Effect
useEffect(() => {
  if (submitted) {
    post("/api/orders", form);
  }
}, [submitted]);
// ✅ همان‌جا که اتفاق می‌افتد، در Event Handler انجامش دهید
async function handleSubmit(e) {
  e.preventDefault();
  await post("/api/orders", form);
}

قانون: کدی که چون کاربر کاری کرده اجرا می‌شود، جایش در Event Handler است. کدی که چون کامپوننت نمایش داده شده اجرا می‌شود، جایش در Effect است.

۹. ریست کردن State با تغییر یک Prop

// ❌ ریست با Effect باعث نمایش لحظه‌ای محتوای قبلی می‌شود
useEffect(() => {
  setComment("");
}, [postId]);
// ✅ به کامپوننت key بدهید — React یک نمونه‌ی تازه می‌سازد
<CommentBox key={postId} postId={postId} />

تغییر key به React می‌گوید این یک کامپوننت متفاوت است و همه‌ی Stateهایش خودکار ریست می‌شوند.

۱۰. زنجیره‌ی Effectها

// ❌ Effect اول Stateی را تغییر می‌دهد که Effect دوم را اجرا می‌کند و...
useEffect(() => { if (country) setCity(null); }, [country]);
useEffect(() => { if (city === null) setDistrict(null); }, [city]);

زنجیره‌ها رندرهای متعدد ایجاد می‌کنند و دنبال کردنشان سخت است. همه را در یک Event Handler محاسبه کنید:

function handleCountryChange(next) {
  setCountry(next);
  setCity(null);
  setDistrict(null);
}

نکته‌ی اضافه: چرا Effect من دو بار اجرا می‌شود؟

در محیط توسعه، StrictMode عمداً کامپوننت‌ها را Mount، Unmount و دوباره Mount می‌کند تا Cleanupهای جاافتاده را نشان دهد. اگر دو بار اجرا شدن برنامه‌تان را خراب می‌کند، Effect شما تکرارپذیر (Idempotent) نیست یا Cleanup ندارد. در Production فقط یک بار اجرا می‌شود.

جای واقعی دریافت داده کجاست؟

Effectهای دستی Fetch باید وضعیت بارگذاری، خطا، کش، Race Condition و دریافت مجدد را مدیریت کنند. کتابخانه‌هایی مثل TanStack Query یا Loaderهای فریم‌ورک همه‌ی این‌ها را حل کرده‌اند:

const { data: user, isLoading, error } = useQuery({
  queryKey: ["user", userId],
  queryFn: ({ signal }) => fetch(`/api/users/${userId}`, { signal }).then((r) => r.json()),
});

چک‌لیست قبل از نوشتن هر Effect

  • آیا می‌توانم این را در زمان رندر محاسبه کنم؟ ← Effect لازم نیست.
  • آیا پاسخ به کار کاربر است؟ ← Event Handler.
  • آیا باید با تغییر Prop، State ریست شود؟ ← key.
  • آیا با چیزی بیرون از React هماهنگ می‌شوم؟ ← Effect، همراه با Cleanup.

جمع‌بندی

بیشتر مشکلات useEffect از استفاده‌ی آن برای کارهایی است که Side Effect نیستند. مقادیر را در زمان رندر محاسبه کنید، کارهای کاربر را در Event Handler انجام دهید، State را با key ریست کنید و Effectهای واقعی را کوچک، صادق درباره‌ی وابستگی‌ها و همیشه همراه با Cleanup نگه دارید. کامپوننت‌هایتان ساده‌تر، سریع‌تر و دیباگشان بسیار آسان‌تر می‌شود.

پرسش‌های متداول

آیا داشتن Effectهای زیاد در یک کامپوننت بد است؟

به‌خودی‌خود نه. Effectهای جدا برای دغدغه‌های نامرتبط بهتر از یک Effect بزرگ است. اما Effectهای زیاد اغلب نشان می‌دهد منطقی باید جای دیگری یا در یک هوک سفارشی باشد.

useEffect یا useLayoutEffect؟

به‌طور پیش‌فرض useEffect. فقط وقتی باید قبل از نقاشی صفحه DOM را اندازه بگیرید یا تغییر دهید تا پرش دیداری رخ ندهد، useLayoutEffect را استفاده کنید.

آیا می‌توانم Callback خود Effect را async کنم؟

مستقیماً نه؛ Effect باید یک تابع Cleanup یا هیچ چیز برگرداند. یک تابع async داخل Effect تعریف و آن را صدا بزنید.