همانطور که از نام آن پیداست، تابع cleanup در هوک useEffect وظیفه پاکسازی اثرات جانبی ناخواسته را بر عهده دارد و از بروز رفتارهای غیرمنتظره در اپلیکیشن جلوگیری میکند. این تابع به ما اجازه میدهد پیش از آنکه کامپوننت از صفحه خارج شود (unmount)، کدهای مرتبط را پاکسازی و مرتب کنیم.
هر بار که کامپوننت رندر میشود و useEffect مجدداً اجرا میگردد، ابتدا تابع cleanup اجرا میشود تا اثرات باقیمانده از اجرای قبلی حذف شود، سپس useEffect دوباره فراخوانی میگردد. زمان اجرای useEffect به آرایه وابستگیها بستگی دارد:
در طراحی هوک useEffect، امکان بازگرداندن یک تابع از درون آن فراهم شده است؛ این تابع همان تابع cleanup است. وظیفه اصلی این تابع، جلوگیری از memory leak است؛ حالتی که اپلیکیشن تلاش میکند state بخشی از حافظهای را تغییر دهد که دیگر وجود ندارد. علاوه بر آن، تابع cleanup برای حذف رفتارهای جانبی ناخواسته نیز کاربرد دارد.
نکته مهمی که باید رعایت شود این است که نباید درون تابع state ،cleanup را تغییر دهیم؛ چرا که این کار ممکن است منجر به رفتارهای پیشبینینشده در اپلیکیشن شود.
useEffect(() => { effect; return () => { cleanup; }; }, [input]);
تابع cleanup در هوک useEffect به توسعهدهندگان کمک میکند اثراتی را که ممکن است منجر به رفتارهای ناخواسته شوند، از بین ببرند و در نتیجه عملکرد اپلیکیشن را بهینه نمایند.
اما نکته قابل توجه این است که این تابع فقط هنگام unmount شدن کامپوننت اجرا نمیشود؛ بلکه پیش از اجرای اثر جدید بعدی نیز فعال میگردد.
در واقع، بعد از اجرای هر effect، اجرای بعدی بر اساس آرایه وابستگیها زمانبندی میشود:
// The `dependency` in the code below is an array useEffect(callback, dependency)
بنابراین، هرگاه effect ما به یک prop وابسته باشد، یا ما چیزی راهاندازی کنیم که حالت ماندگار داشته باشد، دلایل قانعکنندهای برای اجرای تابع cleanup خواهیم داشت.
یک سناریو را بررسی میکنیم: فرض کنید میخواهیم اطلاعات یک کاربر خاص را با استفاده از
id
او از سرور دریافت کنیم. اما پیش از آن که این درخواست کامل شود، نظرمان عوض میشود و تصمیم میگیریم اطلاعات کاربر دیگری را دریافت نماییم.
در این شرایط، هر دو درخواست fetch به کار خود ادامه میدهند، حتی اگر کامپوننت از بین رفته باشد یا وابستگیها تغییر کرده باشند. این میتواند به رفتارهای غیرمنتظره یا خطاهایی منجر شود، مثل نمایش اطلاعات قدیمی یا تلاش برای بهروزرسانی کامپوننتی که دیگر در DOM وجود ندارد. بنابراین باید با استفاده از تابع cleanup، درخواست fetch قبلی را متوقف کنیم.
فرض کنید یک کامپوننت React داریم که دادههایی را از سرور دریافت کرده و نمایش میدهد. اگر کامپوننت ما قبل از اتمام اجرای Promise از بین برود، هوک useEffect سعی میکند state را در کامپوننتی که دیگر وجود ندارد بهروزرسانی کند. در این حالت، React هشدار زیر را نمایش میدهد:
Warning: Can't perform a React state update on an unmounted component. This is a no-op, but it indicates a memory leak in your application. To fix, cancel all subscriptions and asynchronous tasks in a useEffect cleanup function. in Post (at App.js:13)
این هشدار در نسخههای ۱۷ و پایینتر React نمایش داده میشود و در نسخه React 18 حذف شده است. علت حذف آن این بود که React در واقع راه دقیقی برای تشخیص memory leak ندارد. در نسخههای قبل، هرگونه بهروزرسانی state پس از unmount شدن کامپوننت، به عنوان احتمال memory leak علامتگذاری میشد، در حالی که اغلب این موارد واقعاً memory leak نبودند. در نتیجه، توسعهدهندگان زمان زیادی را صرف نوشتن راهحلهایی برای حذف این هشدارهای کاذب میکردند.
اگرچه در نسخه ۱۸ به بعد این هشدار دیگر نمایش داده نمیشود، اما همانطور که در ادامه خواهیم دید، همچنان باید از تابع cleanup استفاده کنیم تا subscriptionها و سایر side effectهایی که ممکن است باعث memory leak شوند، لغو گردند. همچنین، در مواقع لازم باید درخواستهای fetch را نیز لغو کنیم تا تجربه کاربری بهتری فراهم شود. در ادامه نیز به بررسی چند راهکار برای حذف هشدار بالا در نسخههای پایینتر React خواهیم پرداخت.
طبق مستندات رسمی React:
«تابع cleanup در هوک useEffect نهتنها در زمان unmount شدن اجرا میشود، بلکه پیش از هر بار رندر مجدد که در آن وابستگیها تغییر کرده باشند نیز اجرا خواهد شد. همچنین، در حالت development، ریاکت پس از mount شدن کامپوننت، فرایند setup و cleanup را یک بار دیگر نیز اجرا میکند.»
با ارسال یک آرایه خالی بهعنوان لیست وابستگیها، میتوانیم تعیین کنیم که useEffect فقط یکبار اجرا شود. وقتی آرایه وابستگی خالی باشد، یعنی effect به هیچیک از مقادیر state یا prop وابسته نیست. در نتیجه فقط پس از رندر اولیه اجرا خواهد شد و در رندرهای بعدی اجرا نمیشود؛ مگر اینکه کامپوننت unmount و دوباره mount شود:
useEffect(() => { // Effect implementation }, []); // Empty dependency array indicates the effect should only run once
اکنون که متوجه شدیم چگونه میتوانیم هوک useEffect را فقط یکبار اجرا کنیم، به بحث تابع cleanup برمیگردیم.
تابع cleanup معمولاً برای لغو subscriptionها و درخواستهای async فعال استفاده میشود. در ادامه یک مثال را بررسی میکنیم تا ببینیم چگونه میتوانیم این لغوها را پیادهسازی نماییم.
برای شروع پاکسازی یک Subscription، ابتدا باید آن را unsubscribe کنیم . این کار مانع از بروز memory leak در اپلیکیشن میشود و به بهینهسازی عملکرد کمک میکند.
برای لغو Subscription پیش از unmount شدن کامپوننت، میتوانیم متغیری به نام
isApiSubscribed
تعریف کنیم و در ابتدا مقدار آن را برابر true
قرار دهیم. سپس هنگام unmount شدن، آن را به false
تغییر دهیم:
useEffect(() => { // set our variable to true let isApiSubscribed = true; axios.get(API).then((response) => { if (isApiSubscribed) { // handle success } }); return () => { // cancel the subscription isApiSubscribed = false; }; }, []);
برای لغو یک درخواست fetch، میتوانیم از دو روش استفاده کنیم: یا از API مربوط به
AbortController
بهره بگیریم یا از Axios cancel token.
برای استفاده از
AbortController
، باید یک کنترلر با constructor AbortController()
ایجاد کنیم. سپس هنگام شروع درخواست fetch، AbortSignal
را به عنوان گزینه درون آبجکت option
درخواست ارسال مینماییم.
این کار باعث میشود که کنترلر و سیگنال با درخواست fetch مرتبط شوند و بتوانیم در هر زمان دلخواه، با فراخوانی متد
AbortController.abort()
آن را لغو کنیم.
useEffect(() => { const controller = new AbortController(); const signal = controller.signal; fetch(API, { signal: signal, }) .then((response) => response.json()) .then((response) => { // handle success }); return () => { // cancel the request before component unmounts controller.abort(); }; }, []);
با این رویکرد، میتوانیم مدیریت خطا را نیز بهینه نماییم. کافی است در بلاک
catch
بررسی کنیم که آیا خطا ناشی از عملیات abort بوده یا خیر. اگر چنین باشد، دیگر نیازی به بهروزرسانی state نخواهیم داشت. این کار از بهروزرسانیهای غیرضروری در هنگام خروج از کامپوننت جلوگیری میکند و مدیریت lifecycle را بهبود میبخشد.
useEffect(() => { const controller = new AbortController(); const signal = controller.signal; fetch(API, { signal: signal }) .then((response) => response.json()) .then((response) => { // handle success console.log(response); }) .catch((err) => { if (err.name === 'AbortError') { console.log('successfully aborted'); } else { // handle error } }); return () => { // cancel the request before component unmounts controller.abort(); }; }, []);
حالا، حتی اگر کاربر پیش از دریافت نتیجه، صفحه را ترک کند یا به مسیر دیگری هدایت شود، دیگر هشداری دریافت نخواهیم کرد؛ زیرا درخواست قبل از unmount شدن کامپوننت، لغو شده و از بهروزرسانی state جلوگیری میشود.
برای پیادهسازی همین رویکرد با Axios، ابتدا
CancelToken.source()
را در متغیری مثل source ذخیره میکنیم. سپس این توکن را بهعنوان یکی از گزینههای Axios به درخواست اضافه کرده و در صورت نیاز، میتوانیم با source.cancel()
آن را لغو کنیم.
useEffect(() => { const CancelToken = axios.CancelToken; const source = CancelToken.source(); axios .get(API, { cancelToken: source.token }) .catch((err) => { if (axios.isCancel(err)) { console.log('successfully aborted'); } else { // handle error } }); return () => { // cancel the request before component unmounts source.cancel(); }; }, []);
مشابه
AbortError
در AbortController
، در Axios نیز میتوانیم با استفاده از متد isCancel
بررسی کنیم که علت خطا لغو دستی بوده یا خیر. اگر خطا به دلیل لغو باشد، نباید state را بهروزرسانی کنیم.
باید به این نکته توجه داشته باشیم که API مربوط به
CancelToken
در Axios منسوخ شده و صرفاً جهت پشتیبانی از پروژههای قدیمی ذکر میشود. برای پروژههای جدید، توصیه میشود از AbortController
استفاده کنیم.
برای درک بهتر استفاده از تابع cleanup، یک مثال عملی را بررسی میکنیم. فرض کنید دو فایل داریم:
Post
و App
.
در فایل
Post
، اطلاعات مربوط به پستها هنگام mount شدن کامپوننت دریافت میشود و در صورت بروز خطا نیز مدیریت مناسبی انجام میگیرد. سپس این کامپوننت را در فایل App
import کرده و با کلیک روی یک دکمه، کامپوننت را mount و unmount میکنیم.
// Post component import React, { useState, useEffect } from "react"; export default function Post() { const [posts, setPosts] = useState([]); const [error, setError] = useState(null); useEffect(() => { const controller = new AbortController(); const signal = controller.signal; fetch("https://jsonplaceholder.typicode.com/posts", { signal: signal }) .then((res) => res.json()) .then((res) => setPosts(res)) .catch((err) => setError(err)); }, []); return ( <div> {!error ? ( posts.map((post) => ( <ul key={post.id}> <li>{post.title}</li> </ul> )) ) : ( <p>{error}</p> )} </div> ); }
// App component import React, { useState } from "react"; import Post from "./Post"; const App = () => { const [show, setShow] = useState(false); const showPost = () => { // toggles posts onclick of button setShow(!show); }; return ( <div> <button onClick={showPost}>Show Posts</button> {show && <Post />} </div> ); }; export default App;
اگر کاربر روی دکمه کلیک کند و قبل از نمایش پستها دوباره روی آن کلیک نماید، یعنی پیش از دریافت پاسخ، کامپوننت
Post
از DOM حذف شود، در نسخههای ۱۷ به پایین React ممکن است هشدار زیر در کنسول نمایش داده شود:
Warning: Can't perform a React state update on an unmounted component. This is a no-op, but it indicates a memory leak in your application. To fix, cancel all subscriptions and asynchronous tasks in a useEffect cleanup function. at Post (https://luzwr.csb.app/src/Post.js:26:41)
دلیل این هشدار این است که useEffect همچنان در حال اجراست و پس از دریافت پاسخ API، قصد بهروزرسانی state کامپوننتی را دارد که دیگر در DOM موجود نیست.
برای حذف این هشدار، کافی است از
AbortController
در تابع cleanup استفاده کنیم. به این صورت که پیش از unmount شدن، درخواست fetch را لغو کرده و مانع بهروزرسانی state شویم.
// Post component import React, { useState, useEffect } from "react"; export default function Post() { const [posts, setPosts] = useState([]); const [error, setError] = useState(null); useEffect(() => { const controller = new AbortController(); const signal = controller.signal; fetch("https://jsonplaceholder.typicode.com/posts", { signal: signal }) .then((res) => res.json()) .then((res) => setPosts(res)) .catch((err) => { setError(err); }); return () => controller.abort(); // clean up function }, []); return ( <div> {!error ? ( posts.map((post) => ( <ul key={post.id}> <li>{post.title}</li> </ul> )) ) : ( <p>{error}</p> )} </div> ); }
ما حتی پس از لغو با
abort()
، ممکن است همچنان هشدار را در کنسول مشاهده نماییم. دلیل این امر، تلاش برای بهروزرسانی state در داخل بلاک catch
پس از بروز AbortError
است. برای جلوگیری از این هشدار، در داخل catch
باید بررسی کنیم که آیا خطا از نوع AbortError
بوده یا خیر، و در این صورت از بهروزرسانی state صرفنظر کنیم.
// Post component import React, { useState, useEffect } from "react"; export default function Post() { const [posts, setPosts] = useState([]); const [error, setError] = useState(null); useEffect(() => { const controller = new AbortController(); const signal = controller.signal; fetch("https://jsonplaceholder.typicode.com/posts", { signal: signal }) .then((res) => res.json()) .then((res) => setPosts(res)) .catch((err) => { if (err.name === "AbortError") { console.log("successfully aborted"); } else { setError(err); } }); return () => controller.abort(); }, []); return ( <div> {!error ? ( posts.map((post) => ( <ul key={post.id}> <li>{post.title}</li> </ul> )) ) : ( <p>{error}</p> )} </div> ); }
نکته مهم: هنگام استفاده از Fetch API باید بررسی
err.name === "AbortError"
انجام شود، و اگر از Axios استفاده میکنیم، باید از متد axios.isCancel()
استفاده نماییم.
با این توضیحات، اکنون ما بهخوبی میدانیم چطور باید برای جلوگیری از memory leak، خطاهای ناخواسته و هشدارهای کنسول از تابع cleanup در هوک useEffect استفاده نماییم.
هوک useEffect در React برای مدیریت side effectها بسیار مفید است. با این حال، گاهی اوقات ممکن است رفتاری غیرمنتظره از آن مشاهده شود. این رفتار معمولاً ناشی از استفاده نادرست، حذف وابستگیهای لازم یا پیادهسازی نامناسب تابع cleanup است.
بنابراین، اگر با شرایطی مواجه شدیم که useEffect ما رفتار عجیبی داشت، اقدامات زیر میتوانند کمککننده باشند:
در این راهنما دیدیم که چگونه میتوانیم با استفاده از تابع cleanup در useEffect از memory leak جلوگیری کرده و عملکرد برنامه را بهبود بخشیم. اما در برخی موارد، استفاده از این تابع الزامی نیست.
برای مثال، اگر useEffect ما دارای شرایط زیر باشد، ممکن است نیازی به تابع cleanup نداشته باشیم:
[]
) استفاده میکند و به هیچ سرویسی وابسته نیست که هنگام unmount شدن کامپوننت یا پیش از رندر مجدد نیاز به بستن یا پاکسازی داشته باشد.به عنوان مثال:
import { useEffect } from 'react'; function Page({ title }) { useEffect(() => { document.title = title; }, [title]); return <h1>{title}</h1>; }
در کد بالا، با اینکه یک اثر اعمال شده، اما نیازی به تابع cleanup نیست. این اثر مستقل است، side effect بیرونی ندارد و وابستگی آن (
title
) بهدرستی در آرایه آمده است. علاوه بر این، اگر استفاده از useEffect واقعاً ضروری نیست، بهتر است از سایر هوکهای مناسبتر استفاده شود.
در اغلب مواقع، از useEffect برای تعامل با دنیای بیرون از React استفاده میکنیم، بهطوریکه این تعامل باعث اختلال در سیستم رندر React نشود و عملکرد را کاهش ندهد.
هوک useEffect در React ابزار ارزشمندی برای مدیریت side effectها است، اما استفاده نادرست از آن میتواند به رفتارهای غیرمنتظره منجر شود. برای جلوگیری از مسائلی مانند فراخوانی ناخواسته یا memory leakها، لازم است آن را در ساختار منطقی کامپوننتها قرار دهیم (مثلاً قبل یا بعد از توابع دیگر در صورت نیاز) و وابستگیها را بهدرستی مدیریت کنیم.
استفاده مؤثر از توابع cleanup میتواند از اثرات ناخواسته جلوگیری کرده و عملکرد برنامه را بهبود دهد. با درک زمان و چرایی فراخوانی useEffect، میتوانیم lifecycle کامپوننتهای React را با اطمینان مدیریت کرده و از مشکلات رایج دور بمانیم.