مقدمه
تقریباً همه در React از useEffect استفاده کردهایم؛ اغلب برای fetch، تایمر یا event listener. اما بخش کماهمیتگرفتهشدهٔ ماجرا همان cleanup است. نبود یا اشتباهنویسی آن باعث memory leak، لیسنرهای تکراری و خطای معروف setState on unmounted component میشود. در این مطلب، با نمونههای واقعی، نحوهٔ درست نوشتن cleanup را مرور میکنیم.
useEffect چه میکند و cleanup دقیقاً چیست؟
useEffect برای کارهایی است که مستقیماً به رندر وابسته نیستند (side effect). ساختار کلی:
useEffect(() => {
// effect: هر کاری که بیرون از رندر لازم است
return () => {
// cleanup: جمعکردن هر منبعی که باز کردهایم
};
}, deps);
cleanup همان تابع برگشتی است که React در دو حالت اجرا میکند:
- قبل از اجرای مجدد effect وقتی یکی از
depsعوض شود - وقتی کامپوننت unmount میشود
در حالت Strict Mode (dev) اثرها دوبار اجرا میشوند تا باگها زودتر دیده شوند؛ پس نوشتن cleanup درست اهمیت بیشتری دارد.
چه زمانی cleanup اجرا میشود؟
-
قبل از اجرای effect بعدی
اگرdepsتغییر کند، ابتدا cleanup مربوط به اجرای قبلی صدا زده میشود، بعد effect جدید اجرا میشود. -
لحظهٔ unmount
وقتی کامپوننت از DOM حذف شود، cleanup آخرین اجرای effect فراخوانی میشود.
مثال رفتار ترتیب اجرا:
useEffect(() => {
console.log("effect");
return () => console.log("cleanup");
}, [count]);
اگر count از 0 به 1 برسد، خروجی خواهد بود: cleanup → effect.
سناریوهای ضروری cleanup
رویدادهای DOM
useEffect(() => {
const handleResize = () => console.log(window.innerWidth);
window.addEventListener("resize", handleResize);
return () => {
window.removeEventListener("resize", handleResize);
};
}, []);
بدون cleanup، هر بار mount جدید یا تغییر dependency، لیسنر اضافه میشود و چندبار فایر میکند.
تایمرها (setInterval / setTimeout)
useEffect(() => {
const id = setInterval(() => console.log("tick"), 1000);
return () => clearInterval(id);
}, []);
اگر clearInterval را ننویسید، تایمر بعد از حذف کامپوننت هم فعال میماند و منابع CPU را میخورد.
subscription به socket/observable
useEffect(() => {
const unsubscribe = socket.subscribe(data => {
console.log(data);
});
return () => unsubscribe();
}, []);
هر subscription باید یک مسیر خروجی داشته باشد؛ در غیر این صورت، پیامهای قدیمی همچنان به handler قبلی میرسند.
درخواست شبکه با AbortController
useEffect(() => {
const controller = new AbortController();
fetch("/api/data", { signal: controller.signal })
.then(res => res.json())
.then(data => console.log(data))
.catch(err => {
if (err.name !== "AbortError") console.error(err);
});
return () => controller.abort();
}, []);
با abort از setState روی کامپوننت unmounted و لاگ خطای بیربط جلوگیری میکنید.
وابستگیها و باگهای پنهان
- هر بار یکی از
depsتغییر کند، اول cleanup قبلی اجرا میشود؛ پس به refها و stateهای استابل متکی باشید. - اگر handler را داخل effect بسازید و آن را به dependency اضافه نکنید، نسخهٔ قدیمی handler باقی میماند (stale closure). در این موارد از
useCallbackیا dependency صحیح استفاده کنید. - در Strict Mode توسعه، effect → cleanup → effect برای یک mount رخ میدهد؛ پس effect باید idempotent باشد و با cleanup به حالت اولیه برگردد.
اشتباهات رایج که باید ازشان دوری کرد
- نوشتن
asyncمستقیم روی تابعuseEffectو فراموشی abort برای درخواستها - اضافهکردن event listener بدون
removeEventListenerیا با reference متفاوت - تکیه بر
setIntervalبدونclearInterval - بیتوجهی به dependency array و اتکا به مقدارهای قدیمی (stale data)
- فرض اینکه cleanup فقط هنگام unmount اجرا میشود
چکلیست سریع قبل از مرج
- آیا هر منبعی که باز کردید (listener، تایمر، subscription، request) مسیر جمعکردن دارد؟
- آیا dependency array واقعاً تمام متغیرهای استفادهشده را پوشش میدهد یا عمداً خالی گذاشته شده؟
- آیا در dev با Strict Mode، effect و cleanup چندبار اجرا میشوند و رفتارتان همچنان صحیح است؟
- آیا handlerها و callbackها stable هستند تا
removeEventListenerکار کند؟
جمعبندی
تابع cleanup کوچک است اما جلوی باگهای بزرگ را میگیرد. هر بار useEffect مینویسید از خودتان بپرسید: «چه چیزی راه انداختم که باید خاموشش کنم؟» پاسخ به همین سؤال ساده، اپلیکیشن React را از memory leak، listenerهای تکراری و خطاهای setState نجات میدهد.
