آیا تا به حال با یک تولتیپ (tooltip) کار کرده‌اید که از گوشه بالا-چپ به جای درست خود پرش می‌کند؟ یا یک مودال (modal) که قبل از قرارگیری در جای اصلی، با اندازه اشتباه ظاهر می‌شود؟ آن لرزشِ لحظه‌ای، در واقع یک «پرش چیدمان» (layout flicker) است. این اتفاق زمانی رخ می‌دهد که React در حال خواندن DOM، محاسبه‌ی اصلاحیه و به‌روزرسانی وضعیت (state) است، اما مرورگر پیش از آن، شروع به نمایش پیکسل‌ها روی صفحه کرده است. راه حل معمول، جایگزین کردن useEffect با useLayoutEffect است. این جایگزینی کار می‌کند، اما تنها در صورتی که دقیقاً بدانید هر هوک در چه زمانی در خط لوله (pipeline) مرورگر اجرا می‌شود.

خط لوله مرورگر: رندر، کامیت، پینت (Render, Commit, Paint)

React یک کامپوننت را در سه مرحله متمایز به‌روزرسانی می‌کند. در مرحله‌ی render، React ساختار Virtual DOM را می‌سازد (یا بازسازی می‌کند) و تفاوت‌ها (diff) را محاسبه می‌کند. هنوز هیچ تغییر واقعی در پیکسل‌ها رخ نداده است؛ این مرحله صرفاً یک محاسبه در حافظه است. مرحله‌ی بعدی commit است، جایی که React آن تغییرات را روی گره‌های (nodes) واقعی DOM اعمال می‌کند. استایل‌ها به‌روز می‌شوند، گره‌ها درج یا حذف می‌شوند و متن‌ها تغییر می‌کنند.

سپس نوبت به مرورگر می‌رسد. در مرحله‌ی paint، موتور رندر مرورگر هندسه چیدمان (layout geometry) را محاسبه کرده و پیکسل‌ها را روی صفحه ترسیم می‌کند. این توالی ثابت است. مرورگر باید قبل از ترسیم (paint)، چیدمان (layout) را کامل کند و باید قبل از اینکه کاربر چیز جدیدی ببیند، ترسیم را تمام کند. فاصله بین commit و paint با میلی‌ثانیه اندازه‌گیری می‌شود، اما این فاصله واقعی است و دقیقاً همان پنجره‌ای است که در آن useEffect و useLayoutEffect از هم جدا می‌شوند.

چرا useEffect باعث پرش (Flicker) می‌شود

useEffect به‌صورت ناهمگام (asynchronously) اجرا می‌شود و طوری برنامه‌ریزی شده است که پس از اینکه مرورگر صفحه را ترسیم (paint) کرد، اجرا شود. DOM به‌روز شده، پیکسل‌ها ترسیم شده‌اند و سپس React برای اجرای افکت شما وارد عمل می‌شود.

تصور کنید یک منوی کشویی (dropdown menu) زیر یک دکمه رندر می‌کنید. داخل useEffect شما متد buttonRef.current.getBoundingClientRect() را فراخوانی می‌کنید، مختصات صحیح بالا (top) و چپ (left) را محاسبه می‌کنید و آن‌ها را در state ذخیره می‌کنید. از آنجایی که useEffect بعد از paint اجرا می‌شود، مرورگر قبلاً منوی کشویی را در موقعیت پیش‌فرض خود، مثلاً در top: 0, left: 0 ترسیم کرده است. تنها پس از آن paint است که افکت شما state را به‌روز می‌کند. React مختصات اصلاح‌شده را commit می‌کند و مرورگر دوباره paint می‌کند. کاربر دو فریم می‌بیند: ابتدا موقعیت اشتباه و سپس موقعیت درست. آن پرش ناگهانی، همان flicker است که همه سعی در اجتناب از آن دارند.

برای واکشی داده‌ها (data fetching)، فراخوانی‌های API، ردیابی تحلیل‌ها (analytics) یا تنظیم شنونده‌های رویداد (event listeners)، این تأخیر اهمیتی ندارد. برای کاربر مهم نیست که یک سیگنال تحلیل چند میلی‌ثانیه بعد از paint ارسال شود. در واقع، سپردن کارهای غیربصری به بعد از paint باعث می‌شود رندر اولیه پاسخگو (responsive) باقی بماند. اما برای اصلاحات وابسته به چیدمان (layout)، useEffect خیلی دیر عمل می‌کند.

چگونه useLayoutEffect فرآیند پینت را مسدود می‌کند

useLayoutEffect به‌صورت همگام (synchronously) اجرا می‌شود؛ بلافاصله پس از اینکه React در DOM تغییر ایجاد کرد، اما قبل از اینکه مرورگر فرصتی برای محاسبه چیدمان یا ترسیم پیکسل‌ها داشته باشد. این هوک، خط لوله پینت را کاملاً مسدود می‌کند.

اگر همان اندازه‌گیری منوی کشویی را داخل useLayoutEffect انجام دهید، توالی تغییر می‌کند. React به‌روزرسانی اولیه DOM را commit می‌کند، افکت چیدمان شما را اجرا می‌کند و به‌روزرسانی state شما باعث یک رندر مجدد همگام (synchronous re-render) می‌شود. React مختصات اصلاح‌شده را commit می‌کند و تنها پس از آن است که مرورگر paint می‌کند. کاربر تنها یک فریم می‌بیند که از همان ابتدا درست است.

این رفتار مسدودکننده هم یک ویژگی است و هم یک ریسک. از آنجایی که useLayoutEffect مانع از پینت مرورگر تا پایان کار می‌شود، هر محاسبات سنگینی در داخل آن باعث فریز شدن رابط کاربری (UI) می‌شود. حتی چند ده میلی‌ثانیه مسدود شدن پینت، برای کاربر مانند لگ (jank) احساس می‌شود. به همین دلیل است که مستندات React صراحتاً می‌گوید با useEffect شروع کنید و تنها زمانی به سراغ useLayoutEffect بروید که واقعاً متوجه پرشی شوید که قابل تحمل نیست.

چه زمانی از هر هوک استفاده کنیم

بیشتر منطق شما باید در useEffect باشد. از آن برای موارد زیر استفاده کنید:

  • واکشی داده‌ها از یک API
  • تنظیم اشتراک‌ها (subscriptions) یا شنونده‌های رویداد (event listeners)
  • ارسال رویدادهای تحلیل (analytics)
  • هرگونه اثر جانبی (side effect) که بلافاصله چیدمان را نمی‌خواند یا تغییر نمی‌دهد

useLayoutEffect را برای عملیاتی رزرو کنید که باید قبل از اینکه کاربر فریم را ببیند، DOM را بخوانند و تغییرات را اعمال کنند:

  • اندازه‌گیری ابعاد المان‌ها، مانند عرض (width)، ارتفاع (height) یا موقعیت اسکرول (scroll position)
  • محاسبه مختصات برای تولتیپ‌ها، پاپ‌اورها (popovers) یا منوهای راست‌کلیک (context menus)
  • جلوگیری از تغییرات محسوس چیدمان (layout shifts) زمانی که موقعیت بصری به هندسه رندر شده وابسته است

اگر مطمئن نیستید کدام را انتخاب کنید، پیش‌فرض را useEffect بگذارید. تنها زمانی به سراغ useLayoutEffect بروید که متوجه ناپایداری بصری شوید. همین یک قانون کافی است تا اکثریت قریب به اتفاق اپلیکیشن‌های React به‌راحتی اجرا شوند.

نکته‌ی چالش‌برانگیز رندر سمت سرور (The Server-Side Rendering Gotcha)

اگر از Next.js، Remix یا هر فریم‌ورکی استفاده می‌کنید که React را در سمت سرور رندر می‌کند، با یک هشدار در مورد useLayoutEffect مواجه خواهید شد. از آنجایی که سرور فاقد DOM است، این هوک چیزی برای اندازه‌گیری ندارد. React به شما هشدار می‌دهد که انتظار یک محیط مرورگر را داشت اما آن را پیدا نکرد. در طول فرآیند hydration، این عدم تطابق می‌تواند باعث باگ‌های ظریفی شود، زیرا markup رندر شده در سمت سرور با اولین رندر intended در سمت کلاینت ممکن است متفاوت باشد.

راه حل استاندارد، استفاده از یک هوک ایزومورفیک (isomorphic hook) است که اثر (effect) مناسب را بر اساس محیط انتخاب می‌کند:

const useIsomorphicLayoutEffect =
  typeof window !== 'undefined' ? useLayoutEffect : useEffect;

از این wrapper در هر کامپوننتی که باید گره‌های DOM را اندازه‌گیری کند اما ممکن است در حین رندر سمت سرور اجرا شود، استفاده کنید. این کار باعث ساکت شدن هشدار شده و خروجی سرور شما را یکپارچه نگه می‌دارد.

عملکرد و بهترین روش‌ها

از آنجایی که useLayoutEffect فرآیند painting را مسدود می‌کند، بدنه هوک را تا حد امکان سبک نگه دارید. مقدار layout را بخوانید، اصلاحیه را محاسبه کنید و آن را بازنویسی کنید. در داخل آن داده‌ها را fetch نکنید، اشیاء بزرگ را parse نکنید یا الگوریتم‌های سنگین را اجرا نکنید. کد سنگین در اینجا باعث متوقف شدن main thread شده و باعث می‌شود رابط کاربری شما منجمد به نظر برسد.

هنگام اندازه‌گیری المان‌ها، به جای document.getElementById از React refs استفاده کنید. Refs به نمونه (instance) کامپوننت شما متصل هستند، بدون نیاز به ترفندهای کوئری در رندرهای مجدد باقی می‌مانند و با portals یا رندرینگ شرطی به شکلی قابل اعتماد کار می‌کنند. جستجوهای ID سراسری، کپسوله‌سازی (encapsulation) کامپوننت را از بین می‌برند و می‌توانند دقیقاً در لحظه‌ای که به آن‌ها نیاز دارید، مقدار null برگردانند.

useEffect گزینه پیش‌فرض و مناسب برای تقریباً هر side effect است. این هوک اجازه می‌دهد مرورگر بدون وقفه painting را انجام دهد و داده‌ها، رویدادها و همگام‌سازی خارجی را به شکلی تمیز مدیریت می‌کند. useLayoutEffect ابزاری تخصصی برای یک مشکل خاص است: خواندن layout و بازنویسی آن قبل از انجام paint. با تسلط بر تفاوت زمانی بین آن‌ها، دیگر به دنبال رفع پرش‌های تصویری (flickers) نخواهید بود، بلکه از بروز آن‌ها جلوگیری خواهید کرد.

نکته اصلی: برای همه چیز با useEffect شروع کنید. لحظه‌ای که می‌بینید یک tooltip یا modal قبل از اینکه خودش را اصلاح کند، در جای اشتباهی چشمک می‌زند، این سیگنال شماست. به useLayoutEffect سوئیچ کنید، DOM را اندازه‌گیری کنید، layout خود را تنظیم کنید و اجازه دهید مرورگر فقط یک بار — و به درستی — paint را انجام دهد.