همانطور که متوجه شدیم، ریآکت در محیط توسعه (Development) و به دلیل فعال بودن Strict Mode، کامپوننتها را یک بار فوراً حذف و دوباره متولد (Remount) میکند. هدف ریآکت از این کار، آزار دادن شما نیست؛ بلکه میخواهد کدهای معیوب و نشتهای حافظه (Memory Leaks) را قبل از رسیدن به محیط Production آشکار کند.
بنابراین، صورتمسئله این نیست که «چطور جلوی اجرای دوباره افکت را بگیرم؟»، بلکه این است که «چطور افکتم را اصلاح کنم که پس از اجرای چرخه Setup → Cleanup → Setup بدون باگ کار کند؟»
چرا استفاده از Ref برای متوقف کردن اجرای دوبارۀ افکت، یک تله و باگ بزرگ است؟
الگوهای استاندارد پیادهسازی کلینآپ (Cleanup) برای ریکوئستها، انیمیشنها و ویجتها
تشخیص کدهایی که اساساً نباید داخل useEffect نوشته شوند
یکی از اشتباهات رایج میان توسعهدهندگان این است که یک رف (useRef) تعریف میکنند تا با یک شرط ساده، مانع از اجرای بار دوم افکت در محیط توسعه شوند:
// ❌ این کد باگ برنامه شما را حل نمیکند، فقط صورت مسئله را پاک میکند!
const hasFired = useRef(false);
useEffect(() => {
if (!hasFired.current) {
hasFired.current = true;
connectToChat();
}
}, []);
چرا این کار اشتباه است؟ با این کار، شما در محیط لکال فقط یک بار پیام اتصال را میبینید، اما باگ اصلی همچنان زنده است. اگر کاربر به صفحه دیگری برود، اتصال سرور قطع نخواهد شد و اگر دوباره بازگردد، یک اتصال جدید روی قبلی انباشته میشود! هدف ریآکت از اجرای دوبارۀ افکت، دقیقاً این بود که به شما بفهماند کدی برای قطع اتصال (Disconnect) ننوشتهاید.
بسیاری از افکتهایی که در پروژههای خود مینویسید، در یکی از دستهبندیهای استاندارد زیر قرار میگیرند:
اگر از ابزاری مثل نقشههای آنلاین (مانند Leaflet یا Google Maps) استفاده میکنید و میخواهید سطح زوم را هماهنگ کنید:
useEffect(() => {
const map = mapRef.current;
map.setZoomLevel(zoomLevel);
}, [zoomLevel]);
در این حالت نیازی به کلینآپ نیست؛ چرا که صدا زدن دو بارۀ متد setZoomLevel با یک مقدار ثابت، هیچ تغییر مخربی در ظاهر نقشه ایجاد نمیکند.
اما برای المانهایی مثل تگ <dialog> بومی مرورگر، صدا زدن متد .showModal() برای بار دوم خطای سیستم ایجاد میکند. پس باید تابع کلینآپ آن را ببندد:
useEffect(() => {
const dialog = dialogRef.current;
dialog.showModal();
return () => dialog.close(); // کلینآپ امن
}, []);
اگر به رویدادهای پنجره مرورگر یا المان خاصی گوش میدهید، حتماً در کدهای بازگشتی آن را حذف کنید:
useEffect(() => {
function handleScroll(e) {
console.log(window.scrollX, window.scrollY);
}
window.addEventListener('scroll', handleScroll);
// لغو اشتراک فرکانس رویداد
return () => window.removeEventListener('scroll', handleScroll);
}, []);
اگر در افکت خود خصوصیات ظاهری را برای شروع انیمیشن تغییر میدهید، کلینآپ باید تنظیمات را به حالت اولیه (Initial) برگرداند:
useEffect(() => {
const node = ref.current;
node.style.opacity = 1; // شروع انیمیشن ظهور
return () => {
node.style.opacity = 0; // بازگشت به حالت اولیه در زمان خروج
};
}, []);
شما نمیتوانید درخواستی را که فرستاده شده از شبکه حذف کنید، اما میتوانید به کمک یک متغیر پرچم (ignore) کاری کنید که نتیجه درخواستهای منقضی شده روی استیت برنامه اثر نگذارد (جلوگیری از باگ Race Condition):
useEffect(() => {
let ignore = false;
async function startFetching() {
const json = await fetchTodos(userId);
if (!ignore) {
setTodos(json); // فقط اگر کامپوننت هنوز معتبر است استیت آپدیت شود
}
}
startFetching();
return () => {
ignore = true; // در رندر بعدی یا Unmount، درخواست قبلی نادیده گرفته میشود
};
}, [userId]);
در بخش Network مرورگر دو درخواست میبینید که کاملاً طبیعی است، اما به لطف بررسی شرط if (!ignore)، هیچ تداخل استیتی پیش نخواهد آمد.
گاهی اوقات ریمونت شدن کامپوننت کدهایی را خراب میکند که اصلاً نباید از ابتدا داخل useEffect نوشته میشدند.
کدهایی که فقط و فقط باید یکبار در زمان لود شدن کل سایت اجرا شوند (مثل بررسی وجود توکن یا خواندن دیتای اولیه از localStorage) را کاملاً بیرون از بدنه کامپوننتها قرار دهید:
// بیرون از کامپوننت ریشه قرار دارد و فقط یکبار هنگام لود صفحه اجرا میشود
if (typeof window !== 'undefined') {
checkAuthToken();
loadDataFromLocalStorage();
}
export default function App() { ... }
قرار دادن درخواست خرید یا ثبت اطلاعات در افکت یک اشتباه فاحش است:
// 🔴 کاملاً اشتباه
useEffect(() => {
fetch('/api/buy', { method: 'POST' });
}, []);
اگر کاربر وارد این صفحه شود، خرید انجام میشود؛ حالا اگر یک لحظه به صفحه دیگری برود و دکمه Back مرورگر را بزند، کامپوننت دوباره Mount شده و محصول برای بار دوم خرید میگردد! فرآیند خرید ناشی از رندر شدن صفحه نیست، بلکه ناشی از کلیک روی یک دکمه است. پس این کد باید مستقیماً داخل Event Handler دکمه خرید قرار گیرد:
function handleClick() {
// ✅ کاملاً درست؛ خرید فقط با کلیک مستقیم انجام میشود
fetch('/api/buy', { method: 'POST' });
}
برای درک عمیقتر، این تصویر ذهنی را داشته باشید که هر رندر، افکتهای اختصاصی خودش را دارد. وقتی استیت تغییر میکند، یک رندر جدید با مقادیر کاملاً جدید ایجاد میشود. ریآکت ابتدا تابع کلینآپِ افکتِ رندر قبلی را اجرا میکند (که به مقادیر قدیمی دسترسی داشت) و سپس افکتِ رندر جدید را با مقادیر تازه اجرا میکند. این ایزولهسازی کامل، به لطف مفهوم Closures در جاوااسکریپت میسر شده است.
تلاش برای دور زدن اجرای دوبارۀ افکتها به کمک رسیورهایی مثل Ref، باگهای پنهان برنامه را ماندگارتر میکند.
قاعده طلایی: خروجی کار برای کاربر نهایی در حالت تولید نباید تفاوتی با چرخه ریمونت شدن (Setup → Cleanup → Setup) در حالت توسعه داشته باشد.
برای درخواستهای شبکه منقضی شده، همیشه از پرچم ignore استفاده کنید تا دچار باگ مسابقه استیتها (Race Conditions) نشوید.
کارهایی که وابسته به اکشن مستقیم کاربر هستند (مثل ارسال فرم یا کلیک خرید) را به هیچ وجه در افکت ننویسید و به Event Handlerها بسپارید.
این محتوا کاملا رایگان توسط تیم کدلپر ترجمه شده و در اختیار شما کاربران عزیز قرار گرفته است، هر گونه کپی برداری برای مقاصد غیر رایگان و بدون ذکر منبع، مورد پیگیری قانونی قرار میگیرد.
ترجمه شده از منبع: https://react.dev/learn