Race Condition و Stale Closure در Effects

در این درس به دو مشکل رایج و به‌هم‌مرتبط می‌پردازیم که معمولاً هنگام کار با Effectهای غیرهمزمان (Async) یا Effectهایی که به مقادیر بیرونی وابسته‌اند رخ می‌دهند: Race Condition و Stale Closure. شناخت این دو مشکل، یکی از مهم‌ترین مهارت‌ها در نوشتن Effectهای درست است.

Race Condition چیست

فرض کنید یک Effect داریم که بر اساس userId، اطلاعات کاربر را از سرور دریافت می‌کند:

function UserProfile({ userId }) {
  const [user, setUser] = useState(null);

  useEffect(() => {
    fetchUser(userId).then((data) => {
      setUser(data);
    });
  }, [userId]);

  return <p>{user?.name}</p>;
}

حالا فرض کنید کاربر به‌سرعت از userId="۱" به userId="۲" تغییر می‌کند. دو درخواست همزمان به سرور ارسال می‌شود: یکی برای کاربر ۱، یکی برای کاربر ۲. اما هیچ تضمینی وجود ندارد که این دو درخواست، به همان ترتیبی که ارسال شده‌اند، پاسخ داده شوند. اگر پاسخ کاربر ۱ دیرتر از پاسخ کاربر ۲ برسد، مقدار نهایی user اشتباهاً برابر با اطلاعات کاربر ۱ می‌شود، در حالی که کاربر روی صفحه، کاربر ۲ را انتظار دارد. به این وضعیت، Race Condition گفته می‌شود.

راه‌حل: نادیده گرفتن نتایج قدیمی با یک پرچم Cleanup

رایج‌ترین راه‌حل، استفاده از یک متغیر محلی در کنار Cleanup است که مشخص می‌کند آیا این اجرای خاص Effect هنوز «معتبر» است یا نه:

useEffect(() => {
  let ignore = false;

  fetchUser(userId).then((data) => {
    if (!ignore) {
      setUser(data);
    }
  });

  return () => {
    ignore = true;
  };
}, [userId]);

با این تغییر، وقتی userId عوض می‌شود، React پیش از اجرای Effect جدید، Cleanup اجرای قبلی را فراخوانی می‌کند و ignore مربوط به آن اجرای قدیمی، برابر true می‌شود. حالا اگر پاسخ درخواست قدیمی دیرتر برسد، شرط if (!ignore) از اعمال شدن آن روی State جلوگیری می‌کند.

Stale Closure چیست

Stale Closure به وضعیتی گفته می‌شود که یک تابع (مانند بدنه یک Effect)، به مقدار قدیمی یک متغیر اشاره می‌کند، در حالی که مقدار واقعی آن متغیر، در همین حین، تغییر کرده است. این مشکل معمولاً از عدم رعایت Dependency Array درست ناشی می‌شود.

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

  useEffect(() => {
    const id = setInterval(() => {
      console.log(count); // همیشه ۰ چاپ می‌شود
    }, 1000);

    return () => clearInterval(id);
  }, []); // Dependency Array خالی، اما count داخل آن خوانده شده
}

در این کد، چون Dependency Array خالی است، Effect فقط یک‌بار اجرا می‌شود؛ و تابعی که داخل setInterval ساخته شده، برای همیشه به همان مقدار count از همان یک رندر اولیه (یعنی ۰) اشاره می‌کند. حتی اگر count در رندرهای بعدی تغییر کند، این Closure قدیمی، هیچ‌وقت از آن باخبر نمی‌شود.

راه‌حل‌های Stale Closure

دو راه‌حل رایج برای این مشکل وجود دارد. راه اول، اضافه کردن مقدار به Dependency Array تا Effect با هر تغییر آن، دوباره اجرا شود (و Closure جدیدی بسازد):

useEffect(() => {
  const id = setInterval(() => {
    console.log(count);
  }, 1000);
  return () => clearInterval(id);
}, [count]);

راه دوم، استفاده از فرم Functional Update که در فصل سوم دیدیم، تا اصلاً نیازی به خواندن مقدار count از بیرون Effect نباشد:

useEffect(() => {
  const id = setInterval(() => {
    setCount((prev) => prev + 1);
  }, 1000);
  return () => clearInterval(id);
}, []);

ریشه مشترک هر دو مشکل

هم Race Condition و هم Stale Closure، در نهایت از یک منبع مشترک می‌آیند: عملیات غیرهمزمان یا تأخیری که به مقداری از یک رندر خاص وابسته است، اما نتیجه یا اجرای آن، ممکن است دیرتر و در شرایطی متفاوت به پایان برسد. رعایت دقیق Dependency Array و استفاده از Cleanup برای نادیده گرفتن نتایج قدیمی، ابزار اصلی مقابله با هر دو مشکل هستند.

مثال قابل اجرا

جلوگیری از Race Condition با پرچم ignore
برای دیدن پیش‌نمایش کامپوننت، روی Run بزنید.

جمع‌بندی

Race Condition زمانی رخ می‌دهد که پاسخ یک عملیات غیرهمزمان قدیمی، دیرتر از یک عملیات جدیدتر برسد و اشتباهاً روی State اعمال شود؛ راه‌حل رایج آن، استفاده از یک پرچم در Cleanup برای نادیده گرفتن نتایج قدیمی است. Stale Closure زمانی رخ می‌دهد که یک تابع به مقدار قدیمی یک متغیر اشاره کند؛ راه‌حل آن، رعایت درست Dependency Array یا استفاده از Functional Update است. در درس بعدی به حلقه‌های بی‌نهایت و مشکلات رایج مرتبط با Dependencyها می‌پردازیم.