فرض کنید کامپوننت چترومی دارید که باید با عوض شدن roomId به اتاق جدید وصل شود و وقتی اتصال برقرار شد، اعلانی (toast) با تم فعلی کاربر نشان بدهد:
function ChatRoom({ roomId, theme }) {
useEffect(() => {
const connection = createConnection(roomId);
connection.on("connected", () => {
showNotification("Connected!", theme);
});
connection.connect();
return () => connection.disconnect();
}, [roomId, theme]);
}
theme داخل effect خوانده میشود، پس linter مجبورتان میکند آن را هم در آرایهی وابستگیها بگذارید. نتیجهاش این است که هر بار کاربر بین حالت روشن و تیره جابهجا شود، effect دوباره اجرا میشود و چت قطع و دوباره وصل میشود. عوض کردن یک رنگ نباید اتصال شبکه را قطع کند.
دو راهحل رایج، و چرا هیچکدام خوب نیست
اولین راه این است که theme را از وابستگیها حذف کنید و یک // eslint-disable-next-line بالای آرایه بگذارید. اتصال دیگر با عوض شدن تم قطع نمیشود، ولی callback همان themeی را نگه میدارد که هنگام اجرای effect وجود داشت و اعلان با رنگ قدیمی نشان داده میشود. بدتر اینکه linter را برای این effect خاموش کردهاید؛ اگر فردا کسی متغیری به آن اضافه کند که واقعاً باید در وابستگیها باشد، هشداری نمیگیرد.
راه دوم نگه داشتن theme در یک ref است:
const themeRef = useRef(theme);
useEffect(() => {
themeRef.current = theme;
});
useEffect(() => {
const connection = createConnection(roomId);
connection.on("connected", () => {
showNotification("Connected!", themeRef.current);
});
connection.connect();
return () => connection.disconnect();
}, [roomId]);
این یکی درست کار میکند، ولی کدی تکراری است که برای هر مقدار و در هر effect باید دوباره بنویسید. خوانندهی کد هم باید بفهمد چرا theme جایی مستقیم خوانده شده و جای دیگر از themeRef.current.
راهحل: useEffectEvent
import { useEffect, useEffectEvent } from "react";
function ChatRoom({ roomId, theme }) {
const onConnected = useEffectEvent(() => {
showNotification("Connected!", theme);
});
useEffect(() => {
const connection = createConnection(roomId);
connection.on("connected", () => onConnected());
connection.connect();
return () => connection.disconnect();
}, [roomId]);
}
effect حالا فقط به roomId وابسته است و linter هم ایرادی نمیگیرد؛ نسخههای جدید eslint-plugin-react-hooks میدانند که Effect Event نباید در وابستگیها بیاید. تم را هر چند بار که بخواهید عوض کنید، اتصال دست نمیخورد. ولی هر وقت اعلان نشان داده شود، onConnected آخرین مقدار theme را میخواند.
چرا یک تابع معمولی کافی نیست
اولین سؤالی که برای من پیش آمد این بود که اگر onConnected را یک تابع معمولی بیرون از effect تعریف کنم، چه فرقی میکند؟
function ChatRoom({ roomId, theme }) {
const onConnected = () => {
showNotification("Connected!", theme);
};
useEffect(() => {
const connection = createConnection(roomId);
connection.on("connected", () => onConnected());
connection.connect();
return () => connection.disconnect();
}, [roomId, onConnected]);
}
مشکل حل نشده، فقط جایش عوض شده. onConnected در هر رندر تابع تازهای است و با Object.is هیچوقت با تابع رندر قبلی برابر نیست. پس effect در هر رندر دوباره اجرا میشود، نه فقط وقتی تم عوض شود. وضع از قبل هم بدتر شده: اگر این کامپوننت state دیگری داشته باشد، مثلاً متنی که کاربر در حال تایپش است، با هر حرفی که تایپ شود چت قطع و وصل میشود.
حالا فرض کنید برای جلوگیری از این، onConnected را از وابستگیها بیرون بگذارید. effect فقط وقتی roomId عوض شود اجرا میشود و onConnectedِ همان رندر را به connection میدهد. آن تابع روی themeِ همان رندر closure بسته است. رندرهای بعدی onConnected تازهای میسازند، ولی connection هیچوقت آنها را نمیبیند. این همان closure کهنه (stale closure) است.
چیزی که این باگ را خطرناک میکند این است که در نگاه اول دیده نمیشود. اعلان اول با تم درست میآید و عوض کردن تم هم ظاهراً هیچ اثری ندارد، که دقیقاً همان رفتاری است که میخواستیم. باگ فقط وقتی پیدا میشود که همان connection دوباره رویداد connected بفرستد؛ مثلاً وقتی کتابخانهی چت بعد از یک قطعی کوتاه خودش دوباره وصل میشود. آن موقع اعلان با تمی نشان داده میشود که کاربر مدتهاست عوضش کرده.
این را در یک آزمایش کوچک میتوانید ببینید. دو handler به یک connection ساختگی وصل شدهاند و effect هر دو فقط یک بار اجرا میشود: یکی تابع معمولی است و دیگری useEffectEvent. «اتصال دوباره» را بزنید؛ هر دو تم light را میخوانند. حالا تم را عوض کنید و دوباره وصل شوید: تابع معمولی هنوز light را میخواند و useEffectEvent تم تازه، یعنی dark را.
useCallback هم کمکی نمیکند:
const onConnected = useCallback(() => {
showNotification("Connected!", theme);
}, [theme]);
onConnected حالا فقط با عوض شدن theme تغییر میکند و دوباره به باگ اول برگشتهایم: عوض کردن تم چت را قطع و وصل میکند. useCallback فقط تا وقتی ورودیهای تابع عوض نشدهاند هویتش را ثابت نگه میدارد. تابعی که باید theme تازه را ببیند، با عوض شدن theme ناچار تابع دیگری میشود.
خلاصه اینکه هیچ تابع معمولیای هر دو ویژگی را با هم ندارد. یا مقدار تازه را میبیند و با هر تغییرش effect را دوباره اجرا میکند، یا از وابستگیها پنهان میشود و مقدار کهنه را میخواند:
| نسخه | آخرین theme را میخواند؟ | با عوض شدن تم دوباره وصل میشود؟ |
|---|---|---|
() => {} در وابستگیها | بله | بله، و در هر رندر دیگر هم |
() => {} بیرون از وابستگیها | نه (کهنه) | نه |
useCallback(..., [theme]) | بله | بله |
useEffectEvent(...) | بله | نه |
مدل ذهنی: واکنشی و غیرواکنشی
برای هر مقداری که داخل effect خوانده میشود، یک سؤال کافی است: اگر این مقدار عوض شد، میخواهم effect دوباره اجرا شود؟
برای roomId جواب مثبت است. اتاق عوض شده و باید به اتاق جدید وصل شد، پس roomId مقداری واکنشی است و در خود effect و وابستگیهایش میماند. برای theme جواب منفی است. آخرین مقدارش را لازم داریم، ولی عوض شدنش دلیلی برای قطع اتصال نیست. این مقدار غیرواکنشی است و جایش در Effect Event است.
همین سؤال جلوی سوءاستفاده را هم میگیرد. اگر عوض شدن مقداری باید effect را دوباره اجرا کند، جایش در وابستگیهاست. پنهان کردنش در یک Effect Event همان باگ eslint-disable را میسازد، فقط با ظاهری تمیزتر.
مثال دوم: ثبت بازدید صفحه
function Page({ url, cartItems }) {
const onVisit = useEffectEvent((visitedUrl) => {
logVisit(visitedUrl, cartItems.length);
});
useEffect(() => {
onVisit(url);
}, [url]);
}
میخواهیم برای هر بازدید صفحه یک لاگ بفرستیم و تعداد کالاهای سبد خرید را هم همراهش ثبت کنیم. اگر cartItems در وابستگیها بود، اضافه کردن کالا به سبد یک «بازدید» جعلی ثبت میکرد.
به این هم دقت کنید که url بهصورت آرگومان به onVisit داده شده، نه اینکه داخل آن از props خوانده شود. url مقدار واکنشی است و effect به خاطر آن اجرا شده؛ وقتی آرگومان باشد، معلوم است هر فراخوانی مربوط به کدام بازدید است. اگر بعدها logVisit را با تأخیر صدا بزنید (مثلاً بعد از یک setTimeout)، باز هم آدرس همان بازدید ثبت میشود، نه آدرسی که کاربر تا آن لحظه به آن رفته.
زیر کاپوت
useEffectEvent در اصل همان الگوی ref است که داخل خود ریاکت پیاده شده. در کد react-dom (نسخهی 19.3) این هوک شیء سادهای به شکل { impl } نگه میدارد. در هر رندر، تابعی که به آن دادهاید کنار گذاشته میشود و ریاکت در فاز commit، قبل از اینکه هیچ layout effect یا effect معمولیای از آن commit اجرا شود، impl را با آن عوض میکند. چیزی که هوک برمیگرداند یک wrapper است که هر بار صدایش بزنید، impl فعلی را اجرا میکند.
از همین کد دو نکته معلوم میشود. اول اینکه اگر wrapper را وسط رندر صدا بزنید، خطا میگیرید:
A function wrapped in useEffectEvent can't be called during rendering.
دوم اینکه wrapper در هر رندر از نو ساخته میشود و هویت ثابتی ندارد. اگر آن را در آرایهی وابستگیها بگذارید، دقیقاً رفتار نسخهی () => {} در وابستگیها را میگیرید و effect در هر رندر دوباره اجرا میشود. linter هم به همین دلیل اصرار دارد بیرون بماند.
در نسخههای قدیمیتر ریاکت، معادل تقریبیاش را میشود خودتان نوشت:
function useEffectEventShim(fn) {
const ref = useRef(fn);
useInsertionEffect(() => {
ref.current = fn;
});
return useCallback((...args) => ref.current(...args), []);
}
useInsertionEffect قبل از همهی layout effectها و effectهای معمولی اجرا میشود، پس وقتی effectی این تابع را صدا میزند، ref آخرین نسخه را دارد. فرقش با نسخهی ریاکت این است که تابع برگشتی ثابت است و صدا زدنش در رندر خطایی نمیدهد. پس اشتباههایی که نسخهی اصلی جلویشان را میگیرد، اینجا بیصدا رد میشوند.
قاعدههایی که باید یادتان بماند
- Effect Event را فقط از داخل effect صدا بزنید، یا از callbackی که effect ثبت کرده (مثل
connection.on). نه در رندر و نه در event handlerی مثلonClick؛ آنجا همان تابع معمولی کافی است، چون در هر رندر با آخرین props ساخته میشود. - آن را در آرایهی وابستگیها نگذارید.
- آن را به کامپوننت یا هوک دیگری پاس ندهید. Effect Event متعلق به همان کامپوننت و effectهای خودش است.
- useEffectEvent از React 19.2 پایدار است. قبل از آن با نام
experimental_useEffectEventفقط در نسخههای experimental وجود داشت و راه عملی همان الگوی ref بود.
اگر در کدتان جایی // eslint-disable-next-line react-hooks/exhaustive-deps بالای آرایهی وابستگیهای یک effect دارید، سراغش بروید. خیلی وقتها پشتش مقداری غیرواکنشی است که جایش در یک useEffectEvent است.