هل سبق لك أن قمت ببناء tooltip يقفز من الزاوية العلوية اليسرى إلى مكانه الصحيح؟ أو نافذة modal تومض بحجم خاطئ قبل أن تستقر في مكانها؟ ذلك الخلل الذي يستغرق جزءاً من الثانية هو layout flicker (وميض في التخطيط). يحدث هذا عندما يقرأ React الـ DOM، ويحسب التصحيح، ويحدث الـ state، ولكن المتصفح يكون قد بدأ بالفعل في عرض البكسلات على الشاشة. الوصفة المعتادة هي استبدال useEffect بـ useLayoutEffect. هذا الاستبدال يعمل، ولكن فقط إذا فهمت بالضبط متى يتم تشغيل كل hook داخل مسار عمل المتصفح (browser pipeline).
مسار عمل المتصفح: Render، Commit، Paint
يقوم React بتحديث المكون عبر ثلاث مراحل متميزة. في مرحلة الـ render، يقوم React ببناء — أو إعادة بناء — الـ Virtual DOM ويحسب الفرق (diff). لا توجد تغييرات فعلية في البكسلات بعد؛ فهذه مجرد عملية حسابية بحتة تحدث في الذاكرة. بعد ذلك تأتي مرحلة الـ commit، حيث يطبق React تلك التغييرات على عقد الـ DOM الحقيقية. يتم تحديث الأنماط (styles)، وتُدرج العقد أو تُحذف، وتتغير النصوص.
ثم يتولى المتصفح الأمر. في مرحلة الـ paint، يقوم محرك الرندرة (rendering engine) في المتصفح بحساب هندسة التخطيط (layout geometry) ورسم البكسلات على الشاشة. هذا التسلسل صارم؛ إذ يجب على المتصفح إكمال التخطيط قبل أن يتمكن من الرسم (paint)، ويجب أن ينتهي من الرسم قبل أن يرى المستخدم أي شيء جديد. الفجوة بين الـ commit والـ paint تُقاس بالملي ثانية، لكنها حقيقية، وهي النافذة التي يختلف فيها useEffect عن useLayoutEffect.
لماذا يتسبب useEffect في الوميض
يعمل useEffect بشكل غير متزامن (asynchronously)، حيث يتم جدولته ليعمل بعد أن يكون المتصفح قد رسم الشاشة بالفعل. يتم تحديث الـ DOM، ورسم البكسلات، ثم يعود React للعمل لتنفيذ الـ effect الخاص بك.
تخيل أنك تقوم برندرة قائمة منسدلة (dropdown menu) أسفل زر. داخل useEffect، تقوم باستدعاء buttonRef.current.getBoundingClientRect()، وتحسب إحداثيات الـ top والـ left الصحيحة، وتخزنها في الـ state. نظرًا لأن useEffect يعمل بعد الـ paint، يكون المتصفح قد رسم القائمة المنسدلة بالفعل في موضعها الافتراضي، ربما عند top: 0, left: 0. فقط بعد عملية الرسم تلك، يقوم الـ effect الخاص بك بتحديث الـ state. يقوم React بعمل commit للإحداثيات المصححة، ويقوم المتصفح بالرسم مرة أخرى. يرى المستخدم إطارين (frames): الموضع الخاطئ، ثم الموضع الصحيح. تلك القفزة البصرية هي الوميض الذي يحاول الجميع تجنبه.
بالنسبة لجلب البيانات (data fetching)، أو استدعاءات الـ API، أو تتبع التحليلات (analytics tracking)، أو إعداد مستمعي الأحداث (event listeners)، فإن هذا التأخير لا يهم. لا يهتم المستخدم ما إذا كان إرسال بيانات التحليلات قد تم بعد بضعة ملي ثوانٍ من عملية الرسم. في الواقع، تأجيل المهام غير المرئية إلى ما بعد الـ paint يحافظ على استجابة الرندرة الأولية. ولكن بالنسبة للتصحيحات المعتمدة على التخطيط (layout-dependent corrections)، فإن useEffect يكون متأخراً جداً ببساطة.
كيف يقوم useLayoutEffect بحظر عملية الـ Paint
يعمل useLayoutEffect بشكل متزامن (synchronously)، مباشرة بعد أن يقوم React بتعديل الـ DOM ولكن قبل أن تتاح للمتصفح فرصة لحساب التخطيط أو رسم البكسلات. إنه يحظر مسار عملية الـ paint بالكامل.
إذا قمت بنفس عملية قياس القائمة المنسدلة داخل useLayoutEffect ، فإن التسلسل يتغير. يقوم React بعمل commit لتحديث الـ DOM الأولي، ثم يشغل الـ layout effect الخاص بك، ويؤدي تحديث الـ state إلى إعادة رندرة (re-render) متزامنة. يقوم React بعمل commit للإحداثيات المصححة، وفقط بعد ذلك يقوم المتصفح بالرسم. يرى المستخدم إطاراً واحداً، وهو صحيح بالفعل.
سلوك الحظر هذا هو الميزة والمخاطرة في آن واحد. نظرًا لأن useLayoutEffect يمنع المتصفح من الرسم حتى ينتهي، فإن أي عملية حسابية ثقيلة بداخله ستؤدي إلى تجميد واجهة المستخدم (UI). حتى بضع عشرات من الملي ثوانٍ من حظر الـ paint ستشعر المستخدم بوجود تعليق (jank). لهذا السبب تخبرك وثائق React صراحةً بأن تبدأ بـ useEffect وأن ترتقي فقط إلى useLayoutEffect عندما تلاحظ بالفعل وميضاً لا يمكنك التسامح معه.
متى تستخدم كل hook
معظم منطق العمل (logic) الخاص بك ينتمي إلى useEffect. استخدمه من أجل:
- جلب البيانات من API
- إعداد الاشتراكات (subscriptions) أو مستمعي الأحداث (event listeners)
- إرسال أحداث التحليلات (analytics events)
- أي تأثير جانبي (side effect) لا يقرأ التخطيط أو يعدله فوراً
احتفظ بـ useLayoutEffect للعمليات التي يجب أن تقرأ الـ DOM وتكتب فيه مرة أخرى قبل أن يرى المستخدم الإطار:
- قياس أبعاد العناصر، مثل العرض (width)، أو الارتفاع (height)، أو موضع التمرير (scroll position)
- حساب الإحداثيات للـ tooltips، أو الـ popovers، أو القوائم السياقية (context menus)
- منع تحركات التخطيط المرئية (layout shifts) عندما يعتمد الموضع المرئي على الهندسة التي تم رندرتها
إذا لم تكن متأكداً أيهما تختار، فاجعل useEffect هو الخيار الافتراضي. انتقل إلى useLayoutEffect فقط عندما تلاحظ عدم استقرار بصري. هذه القاعدة وحدها ستحافظ على تشغيل الغالبية العظمى من تطبيقات React بسلاسة.
مشكلة الـ Server-Side Rendering
إذا كنت تستخدم Next.js أو Remix أو أي إطار عمل يقوم بعملية رندر (rendering) لـ React على الخادم، فستواجه تحذيراً يتعلق بـ useLayoutEffect. نظرًا لأن الخادم لا يحتوي على DOM، فليس لدى الـ hook أي شيء ليقيسه. يحذرك React من أنه كان يتوقع بيئة متصفح ولم يجدها. أثناء عملية الـ hydration، يمكن أن يتسبب هذا التباين أيضاً في أخطاء برمجية طفيفة لأن الـ markup الذي تم رندره على الخادم قد يختلف عن أول عملية رندر مقصودة على جانب العميل.
الحل القياسي هو استخدام isomorphic hook يختار التأثير (effect) المناسب بناءً على البيئة:
const useIsomorphicLayoutEffect =
typeof window !== 'undefined' ? useLayoutEffect : useEffect;
استخدم هذا الـ wrapper في أي مكون (component) يحتاج إلى قياس عقد DOM ولكن قد يتم تنفيذه أثناء عملية الرندر على الخادم. فهو يعمل على إسكات التحذير ويحافظ على اتساق مخرجات الخادم الخاصة بك.
الأداء وأفضل الممارسات
بما أن useLayoutEffect يعيق عملية الرسم (painting)، فاجعل جسم الـ hook خفيفاً قدر الإمكان. اقرأ قيمة التخطيط (layout)، واحسب التصحيح، ثم أعد كتابته. لا تقم بجلب البيانات، أو تحليل كائنات كبيرة، أو تشغيل خوارزميات مكلفة بداخله. الكود الثقيل هنا سيؤدي إلى تعطيل الخيط الرئيسي (main thread) ويجعل واجهة المستخدم تبدو وكأنها متجمدة.
عند قياس العناصر، استخدم React refs بدلاً من document.getElementById. الـ Refs مرتبطة بنسخة المكون الخاص بك، وتصمد أمام عمليات إعادة الرندر دون الحاجة لحيل الاستعلام، وتعمل بشكل موثوق مع الـ portals أو الرندر الشرطي. عمليات البحث عن المعرفات (IDs) العالمية تكسر مبدأ تغليف المكون (component encapsulation) ويمكن أن تعيد null في اللحظة التي تحتاجها فيها تماماً.
يعد useEffect هو الخيار الافتراضي الصحيح لكل تأثير جانبي (side effect) تقريباً. فهو يسمح للمتصفح بالرسم دون انقطاع ويتعامل مع البيانات والأحداث والمزامنة الخارجية بسلاسة. أما useLayoutEffect فهو أداة متخصصة لمشكلة محددة: قراءة التخطيط وإعادة الكتابة قبل عملية الرسم. أتقن الفرق في التوقيت بينهما، وستتوقف عن مطاردة الومضات (flickers) وتبدأ في منع حدوثها.
الخلاصة الحقيقية: ابدأ باستخدام useEffect لكل شيء. في اللحظة التي ترى فيها تلميحاً (tooltip) أو نافذة منبثقة (modal) تومض في مكان خاطئ قبل أن تصحح نفسها، ستكون تلك هي إشارتك. انتقل إلى useLayoutEffect لقياس الـ DOM، وتعديل التخطيط الخاص بك، واترك المتصفح يرسم مرة واحدة—وبشكل صحيح.
