تنتهي معظم دروس أداء React بنفس النصيحة السيئة: قم بتغليف كل شيء بـ useMemo و useCallback وانتهى الأمر. إذا كنت قد اتبعت هذه النصيحة، فمن المحتمل أنك جعلت تطبيقك أبطأ. هذه الـ hooks ليست مجانية؛ فكل منها يخصص ذاكرة، ويقارن التبعيات (dependencies)، ويخزن القيم المخزنة مؤقتًا (cached values). وعند استخدامها دون غرض محدد، تصبح عبئًا إضافيًا بدلاً من أن تكون وسيلة للتحسين.

دعنا نختصر الأمر لنصل إلى ما يهم حقًا.

ما الذي يفعله كل hook حقًا

يقوم useMemo بتذكر قيمة. أنت تعطيه دالة تقوم بعمل شاق، وهو يعيد النتيجة. في عملية الـ render التالية، إذا لم تتغير التبعيات الخاصة بك، سيتخطى React عملية الحساب ويعيد النتيجة القديمة.

يقوم useCallback بتذكر دالة. هو لا يقوم بتشغيل الدالة نيابة عنك، بل ببساطة يعيد نفس نسخة الدالة (function instance) بين عمليات الـ render طالما ظلت تبعياته كما هي.

هذا هو الفرق الجوهري. أحدهما يخزن قيمة محسوبة، والآخر يخزن مرجعًا (reference). الخلط بينهما يؤدي إلى كود يبدو مُحسّنًا ولكنه يتصرف تمامًا مثل الكود غير المغلف، مع استهلاك ذاكرة إضافية.

لماذا تكسر هوية الدالة شجرة المكونات الخاصة بك

عندما يعيد المكون عملية الـ render، يقوم React بتنفيذ جسم الدالة بالكامل مرة أخرى. يتم إعادة إنشاء كل متغير، وتحصل كل دالة مضمنة (inline function) على عنوان جديد تمامًا في الذاكرة.

في JavaScript، الدالتان اللتان تحتويان على نفس المنطق تمامًا ليستا متساويتين. التعبير () => {} === () => {} يعطي نتيجة false. وتنطبق القاعدة نفسها على الكائنات (objects) والمصفوفات (arrays). إذا قام المكون الأب بتعريف handleSubmit ومررها إلى مكون ابن، فإن هذا الابن يتلقى prop جديدًا في كل عملية render. وحتى لو كان الابن مغلفًا بـ React.memo ، فإنه لن يستطيع معرفة أن الدالة الجديدة تفعل الشيء نفسه الذي تفعله الدالة القديمة. لقد تغير المرجع، لذا يعيد الابن عملية الـ render.

هذه هي المشكلة الجذرية التي صُمم useCallback لحلها. الأمر لا يتعلق بالسرعة، بل يتعلق بالاستقرار.

متى يستحق useMemo استخدامه

تحتاج إلى useMemo عندما تقوم بعمل مكلف بشكل موضوعي ويمكنك ملاحظة تأخير ملموس.

فكر في تصفية مجموعة بيانات ضخمة. إذا كان لديك جدول يحتوي على عشرات الآلاف من الصفوف وحقل إدخال للبحث، فقد تكتب شيئًا كهذا داخل مكونك:

const visibleRows = rows.filter(r => r.name.includes(query));

بدون useMemo ، ستعمل هذه الحلقة في كل عملية render. إذا نقر المستخدم على زر يقوم بتبديل الشريط الجانبي، سيعيد المكون الأب عملية الـ render، وستعمل عملية التصفية الخاصة بك مرة أخرى على الرغم من أن rows و query لم يتغيرا أبدًا. في مجموعات البيانات الكبيرة، يكون هذا التلعثم (stutter) مرئيًا.

يقوم useMemo بإصلاح ذلك عن طريق تثبيت النتيجة:

const visibleRows = useMemo(() => {
  return rows.filter(r => r.name.includes(query));
}, [rows, query]);

الآن، لن يعيد React تشغيل تلك التصفية إلا عندما تتغير التبعيات فعليًا.

ينطبق المنطق نفسه على الحسابات الرياضية المعقدة، أو تحويل استجابات API إلى تنسيقات مناسبة للرسوم البيانية، أو اشتقاق حالة (state) قد يتم إعادة حسابها باستمرار لولا ذلك.

هناك حالة استخدام ثانية أقل وضوحًا. إذا قمت بإنشاء كائن (object) أو مصفوفة (array) محليًا وأدرجتها في مصفوفة تبعيات useEffect ، فقد تتسبب عن غير قصد في تشغيل هذا التأثير (effect) في كل عملية render. تحصل الكائنات والمصفوفات المضمنة على هويات جديدة في كل مرة، لذا يرى التأثير تبعية متغيرة ويتم تشغيله مرة أخرى. إن استخدام useMemo لتخزين ذلك الكائن يحافظ على استقرار المرجع ويسمح لتأثيرك بالعمل فقط عندما تتغير البيانات الأساسية فعليًا.

متى يصبح useCallback ضروريًا

تبرز أهمية useCallback بشكل أكبر عندما تمرر معالجات (handlers) إلى مكونات أبناء تم تحسينها باستخدام React.memo.

تخيل مكونًا أبًا يحتوي على عداد. كما يقوم أيضًا بعرض قائمة أبناء مكلفة:

function Parent() {
  const [count, setCount] = useState(0);
  
  const handleItemClick = (id) => {
    console.log(id);
  };
  
  return (
    <div>
      <button onClick={() => setCount(c + 1)}>{count}</button>
      <ExpensiveList onItemClick={handleItemClick} />
    </div>
  );
}

في كل مرة تتغير فيها قيمة count ، يعيد Parent عملية الـ render. يتم إنشاء handleItemClick جديد. ولأن ExpensiveList يتلقى مرجع prop جديدًا، فإنه يعيد عملية الـ render أيضًا. إذا كان ExpensiveList مغلفًا بـ React.memo ، فإن عملية التخزين هذه تضيع تمامًا لأن الـ prop الخاص بالدالة قد تغير.

يحافظ useCallback على المرجع:

const handleItemClick = useCallback((id) => {
  console.log(id);
}, []);

الآن، يعيد ExpensiveList عملية الـ render فقط عندما يحتاج إلى ذلك حقًا.

هناك موقف حرج آخر يتعلق بـ useEffect. إذا كان التأثير يشترك في دالة محددة داخل مكونك، وتتغير هوية تلك الدالة في كل عملية render، فسيتم إلغاء الاشتراك وإعادة الاشتراك بشكل متكرر. إن تخزين الدالة (memoizing) يحافظ على استقرار التأثير.

فخ مصفوفة التبعيات والإغلاقات القديمة (Stale Closures)

يعتمد كلا الـ hooks على مصفوفات التبعيات، وهنا تختبئ معظم الأخطاء البرمجية.

إذا حذفت متغيرًا من مصفوفة التبعيات (dependency array)، فإن دالتك أو قيمتك المُخزنة مؤقتًا (memoized) ستعتمد على نسخة قديمة من ذلك المتغير. هذا ما يُعرف بـ stale closure. قد تعرض واجهة المستخدم بيانات حديثة، لكن دالة الاستدعاء (callback) الخاصة بك لا تزال تنظر إلى حالة (state) من ثلاث عمليات render سابقة. الحل بسيط ولكنه قد يُغفل أثناء مراجعة الكود: قم بتضمين كل قيمة تُستخدم داخل الـ hook والتي قد تتغير.

قم بتشغيل قاعدة ESLint المسماة react-hooks/exhaustive-deps. ستكتشف هذه القاعدة حالات الحذف الواضحة. ولكن لا تتعامل معها كآلة؛ بل افهم لماذا تعتبر كل تبعية مهمة.

الضريبة الخفية للإفراط في التحسين

غالبًا ما يقوم المبتدئون بتغليف كل دالة وكل قيمة بهذه الـ hooks لأن ذلك يمنحهم شعورًا بالأمان. لكن هذه العادة تأتي بنتائج عكسية.

يجب على React تخزين القيم المُخزنة مؤقتًا في الذاكرة. وفي كل عملية render، يجب عليه المرور عبر مصفوفة التبعيات الخاصة بك ومقارنة كل عنصر باستخدام Object.is. هذه المقارنة غير مكلفة، لكنها ليست مجانية. إذا قمت بتغليف معالج أحداث بسيط مثل onClick={() => setOpen(true)} داخل useCallback ، فأنت تدفع تكاليف في الذاكرة والمعالج (CPU) لتجنب إنشاء دالة كان تخصيصها سيتم في لمح البصر.

كما أن الـ hooks تضيف "ضجيجًا" للكود. فالكود المغلف بـ useMemo و useCallback يكون أصعب في القراءة وأصعب في الصيانة. كل مصفوفة تبعيات هي بمثابة stale closure محتمل ينتظر اللحظة المناسبة ليسبب لك مشكلة.

القاعدة الذهبية الحقيقية ليست براقة ولكنها فعالة: اكتب كودًا بسيطًا أولاً. لا تقم بالتحسين إلا عندما يكون لديك دليل على وجود مشكلة. استخدم React DevTools Profiler لتحديد المكونات المكلفة وعمليات الـ render غير الضرورية. إذا كانت عملية الـ render تستغرق أقل من بضعة أجزاء من الثانية، فلن يلاحظ أي مستخدم ذلك، ولن يكون لعملية الـ memoization التي تقوم بها أي فائدة.

الخلاصة

يُستخدم useMemo للقيم المكلفة، بينما يُستخدم useCallback للإشارات المستقرة للدوال (stable function references). لا تجعل أي منهما مكونك (component) يعمل بشكل أسرع بمفرده؛ بل إنهما يمنعان العمل غير الضروري في المراحل اللاحقة. ابدأ بدونهما، واستخدم الأدوات الفعلية للقياس، ثم أضفهما بدقة في الأماكن التي يظهر فيها الـ profiler وجود عنق زجاجة (bottleneck). الكود النظيف الذي يعيد الصيرورة (rerenders) أحيانًا سيتفوق دائمًا تقريبًا على الكود المبالغ في هندسته والذي يقوم بعمل memoization لكل شيء.