صفحه را رفرش می‌کنید و صفحه سفید می‌ماند. یا شاید با کلیک روی یک دکمه، فن CPU شما با تمام توان شروع به کار کند. سپس کنسول این خطای ترسناک را بیرون می‌اندازد: Maximum update depth exceeded. React ترمز را کشیده است، زیرا کامپوننت شما در یک حلقه بی‌نهایت گرفتار شده است. این یکی از رایج‌ترین خطاها در اپلیکیشن‌های React است و معمولاً از یک سوءتفاهم ساده درباره زمان دقیق اجرای کد ناشی می‌شود.

برای رفع آن، باید دقیقاً درک کنید که چه زمانی یک رندر به یک رندر مجدد (re-render) تبدیل می‌شود و چرا تغییرات state باید از مسیر رندر دور بمانند.

نحوه رندر کردن کامپوننت توسط React

React رابط‌های کاربری را از کامپوننت‌ها می‌سازد. در React مدرن، این کامپوننت‌ها تابع هستند. هر بار که React نیاز دارد کامپوننت شما را روی صفحه نمایش دهد، به سادگی آن تابع را فراخوانی می‌کند. در داخل تابع، می‌توانید از state برای به خاطر سپردن موارد بین رندرها استفاده کنید. state به React می‌گوید کدام داده‌ها متعلق به کامپوننت هستند و نکته حیاتی این است که چه زمانی چیزی تغییر کرده و UI نیاز به به‌روزرسانی دارد.

وقتی state تغییر می‌کند، React یک رندر جدید را برنامه‌ریزی می‌کند. تابع کامپوننت دوباره اجرا می‌شود، JSX جدیدی را برمی‌گرداند و React برای مطابقت دادن، DOM را به‌روزرسانی می‌کند. در استفاده‌های عادی، این چرخه بی‌خطر است. شما روی یک دکمه کلیک می‌کنید، یک event handler state را به‌روز می‌کند، React یک بار رندر مجدد انجام می‌دهد و کاربر متن یا رنگ جدید را می‌بیند.

این خطا زمانی ظاهر می‌شود که خودِ رندر باعث ایجاد یک به‌روزرسانی دیگر در state شود. آن به‌روزرسانی جدید، رندر دیگری را تحریک می‌کند و آن هم باعث یک به‌روزرسانی دیگر در state می‌شود. React این وضعیت را برای چند ده چرخه تحمل می‌کند و سپس برای محافظت از مرورگر در برابر هنگ کردن کامل، خطای maximum depth را پرتاب می‌کند.

نگاهی کوتاه به state

قبل از کالبدشکافی این حلقه، به یاد بیاورید که هوک useState چگونه کار می‌کند. این هوک دقیقاً دو چیز به شما می‌دهد: یک متغیر که مقدار فعلی را نگه می‌دارد و یک تابع برای تغییر آن مقدار.

const MessageComponent = () => {
  const [message, setMessage] = useState('Welcome');

  return <h1>{message}</h1>;
};

در اینجا، message در اولین رندر برابر با 'Welcome' است. اگر بعداً setMessage('Goodbye') را فراخوانی کنید، React متوجه تغییر می‌شود، MessageComponent را دوباره فراخوانی می‌کند و حالا UI عبارت "Goodbye" را نشان می‌دهد. همه چیز خوب است زیرا هیچ چیز در خودِ بدنه کامپوننت، تابع setter را به صورت خودکار فراخوانی نمی‌کند. حلقه زمانی شروع می‌شود که setter در طول مرحله رندر و بدون یک رویداد خارجی اجرا شود.

فراخوانی مستقیم SetState در بدنه کامپوننت

مستقیم‌ترین راه برای ایجاد یک حلقه بی‌نهایت، فراخوانی مستقیم تابع setterِ state در بدنه کامپوننت است. از آنجایی که بدنه کامپوننت در هر رندر اجرا می‌شود، setter نیز در هر رندر اجرا می‌گردد. آن به‌روزرسانی جدید در state باعث رندر دیگری می‌شود و این چرخه تا ابد ادامه می‌یابد.

اشتباه به این شکل است:

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

  setCount(count + 1);

  return <div>{count}</div>;
};

هر بار که Counter رندر می‌شود، مقدار count را افزایش می‌دهد. React دوباره رندر می‌کند تا عدد جدید را نشان دهد، دوباره setCount(count + 1) را می‌بیند و یک بار دیگر مقدار را افزایش می‌دهد. راه حل ساده است: هرگز در سطح بالای (top level) کامپوننت خود در حین رندر، یک state setter را فراخوانی نکنید. به‌روزرسانی‌های state باید به رویدادهای کاربر یا side effectها پاسخ دهند، نه به عملِ ترسیم صفحه. در عوض، آن به‌روزرسانی را به یک event handler منتقل کنید:

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

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

تنها استثنا برای فراخوانی setterها در حین رندر زمانی است که در حال محاسبه state جدید از روی props هستید، و حتی در آن صورت هم باید از الگوی متفاوتی استفاده کنید، مانند استخراج مستقیم مقدار یا استفاده عمدی از useEffect.

ارسال فراخوانی تابع به جای ارجاع به آن

علت رایج دیگر، یک غلط تایپی ظریف در JSX است. وقتی یک event handler را متصل می‌کنید، باید خودِ تابع را پاس بدهید. اگر به اشتباه تابع را همان‌جا در JSX فراخوانی کنید، بلافاصله در طول چرخه رندر اجرا می‌شود.

const Toggle = () => {
  const [isOn, setIsOn] = useState(false);

  const handleToggle = () => setIsOn(!isOn);

  return <button onClick={handleToggle()}>Toggle</button>;
};

با نوشتن handleToggle() همراه با پرانتز، شما تابعی را به React نمی‌دهید تا بعداً هنگام کلیک کاربر آن را فراخوانی کند؛ بلکه در همین لحظه، زمانی که React در حال ساخت virtual DOM است، آن را فراخوانی می‌کنید. از آنجایی که handleToggle state را به‌روز می‌کند، کامپوننت دوباره رندر می‌شود. در طول آن رندر مجدد، React دوباره handleToggle() را می‌بیند و باز هم آن را فراخوانی می‌کند. این حلقه هرگز تمام نمی‌شود. نسخه صحیح، پرانتزها را حذف می‌کند:

return <button onClick={handleToggle}>Toggle</button>;

اگر نیاز به ارسال آرگومان دارید، فراخوانی را در یک تابع بی‌نام (anonymous function) قرار دهید:

return <button onClick={() => handleToggle(true)}>Switch On</button>;

این تفاوت حتی توسعه‌دهندگان با تجربه را نیز در حین بازنویسی کد (refactoring) به اشتباه می‌اندازد. مراقب آن پرانتزها باشید.

تله‌ی وابستگی (Dependency Trap) در useEffect

Effectها مکان مناسبی برای انجام side effectهایی مانند دریافت داده‌ها، همگام‌سازی با APIهای مرورگر یا دستکاری دستی DOM هستند. اما useEffect پس از اینکه React رندر را روی صفحه اعمال کرد (commit)، اجرا می‌شود. اگر effect شما state را به‌روز کند، React دوباره رندر خواهد کرد. این حالت معمولاً مشکلی ندارد، اما زمانی تبدیل به یک حلقه می‌شود که effect بعد از هر رندر اجرا شود و همیشه همان state را به‌روز کند.

این الگوی معیوب را در نظر بگیرید:

const UserProfile = () => {
  const [user, setUser] = useState({});

  useEffect(() => {
    setUser({ name: 'Ada', role: 'Admin' });
  });

  return <div>{user.name}</div>;
};

چون هیچ آرایه وابستگی (dependency array) وجود ندارد، این افکت بعد از هر رندر اجرا می‌شود. این افکت user را تنظیم می‌کند که باعث یک رندر جدید می‌شود. پس از آن رندر، افکت دوباره اجرا شده و باز هم user را تنظیم می‌کند. React این چرخه بی‌پایان را تشخیص داده و خطا می‌دهد.

راه حل این است که با ارائه یک آرایه وابستگی مناسب، به React بگویید که افکت دقیقاً چه زمانی باید اجرا شود. اگر قرار است افکت فقط یک بار در هنگام mount اجرا شود، یک آرایه خالی پاس دهید:

useEffect(() => {
  setUser({ name: 'Ada', role: 'Admin' });
}, []);

اگر افکت به یک prop یا بخشی از state وابسته است، فقط همان متغیر را در آرایه قرار دهید. با این حال، مراقب باشید. گنجاندن متغیری که در هر رندر تغییر می‌کند، صرفاً همان حلقه را از مسیری دیگر بازسازی می‌کند. برای مثال، اگر یک object literal را در وابستگی‌ها قرار دهید و آن آبجکت در هر رندرِ والد دوباره ساخته شود، افکت به طور بی‌پایان اجرا خواهد شد. در این موارد، ممکن است لازم باشد ساخت آبجکت را به بیرون از کامپوننت منتقل کنید یا آن را memoize کنید.

مراحل عملی عیب‌یابی

وقتی با این خطا مواجه می‌شوید، stack trace ممکن است گیج‌کننده به نظر برسد، زیرا React قبلاً این چرخه را ده‌ها بار تکرار کرده است. با خواندن ابتدای trace شروع کنید تا بفهمید کدام کامپوننت مکرراً نام برده شده است. سپس به دنبال state setterها در این سه مکان بگردید:

  1. بدنه اصلی کامپوننت، خارج از هر handler یا hook.
  2. ویژگی‌های رویداد (event attributes) در JSX که ممکن است به جای handler از handler() استفاده کرده باشید.
  3. هوک‌های useEffect که فاقد آرایه وابستگی هستند یا به رفرنس‌های ناپایدار (unstable references) وابسته هستند.

هر state setter را به طور موقت کامنت کنید تا خطا متوقف شود. این کار دقیقاً به شما می‌گوید که کدام به‌روزرسانی مقصر است. اگر setter داخل یک افکت است، از خود بپرسید که آیا واقعاً در آنجا به state نیاز دارید یا خیر. گاهی اوقات توسعه‌دهندگان در داخل یک افکت، state محلی را از روی props تنظیم می‌کنند، در حالی که می‌توانستند به سادگی از خودِ prop مستقیماً در JSX استفاده کنند.

نکته اصلی

خطای Maximum update depth یک باگ مرموز در React نیست؛ بلکه یک شبکه ایمنی است. این خطا به این معناست که کامپوننت شما به جای منتظر ماندن برای یک سیگنال خارجی، سعی دارد خودش را دوباره رندر کند. عادتِ برخورد با رندرها به عنوان رویدادهایی که باید state بیشتری تولید کنند را ترک کنید. با رندرها به عنوان پیامدهای خالصِ state برخورد کنید، نه به عنوان علت آن‌ها. به‌روزرسانی‌های state را داخل event handlerها، callbackها یا افکت‌هایی با وابستگی‌های دقیقاً انتخاب‌شده نگه دارید، و دیگر هرگز این خطا را نخواهید دید.