خشبة منهج React — المسرح الحي · ١٣ إقليم · بلا مهلة الستار مرفوع
الإقليم ٠٣١٣ إقليماً

الإقليم ٠٣ — طاقم خلف الكواليس

كل ما بنيتَه حتى الآن نقيّ: دالّةٌ تقرأ props/حالة، تُنتج وصفاً، تنتهي. لا تلمس شبكة، لا تضبط مؤقّتاً، لا تشترك في حدث. لكن أي تطبيق حقيقي يحتاج كل ذلك. هذا الإقليم عن متى بالضبط يجوز لك تنفيذ كودٍ كهذا، ولماذا "متى" هنا مسألة حياة أو موت لتطبيقك.

يبني فوق: الرسم كدالّةٍ نقيّة تُستدعى من جديد بلا توقّع (١)، والإغلاقات كآلية حبس القيم لحظة الاستدعاء (٢). النطاق: متى ينفّذ React كوداً "بعد" استقرار الرسم على الشاشة، وكيف يتعامل مع مغادرة الممثل قبل انتهاء مهمّةٍ معلّقة. يفتح: الفعل مقابل التزامن (٤).

اللغز المستفزّ

عندك مكوّنٌ يعرض بيانات مستخدم، يجلبها حسب userId:

function UserProfile({ userId }: { userId: string }) {
  const [user, setUser] = useState<UserData | null>(null);

  fetch(`/api/users/${userId}`)                    // مباشرة في جسم الدالّة
    .then((res) => res.json())
    .then((data: UserData) => setUser(data));

  return <div>{user ? user.name : "...جارٍ التحميل"}</div>;
}

شغّلها وافتح تبويب Network في DevTools. راقب. لن ترى طلباً واحداً — سترى طوفاناً لا ينتهي من الطلبات، عشرات في الثانية، حتى يتجمّد المتصفّح أو يمتلئ التبويب. لماذا؟ تتبّع بنفسك: fetch يُستدعى في جسم الدالّة مباشرة — أي في كل رسم. أول رسم: fetch يبدأ، وحين يصل الردّ، setUser(data) تُستدعى → هذا يُطلق رسماً جديداً (تذكّر الإقليم ٢) → الرسم الجديد يُنفّذ جسم الدالّة من جديدfetch يبدأ مرّة أخرى → حين يصل ردّه، setUser تُستدعى مجدداً → رسمٌ جديد → fetch من جديد... حلقةٌ لا نهائية، تُغذّي نفسها بنفسها.

السؤال المستفزّ: المشكلة ليست في fetch نفسه — هي في مكان استدعائه. كيف تكتب كوداً "نفّذ هذا حين يظهر المكوّن لأول مرّة، أو حين تتغيّر قيمةٌ معيّنة تحديداً" — بلا أن يُعاد تنفيذه في كل رسمٍ عابر؟

ليش: الرسم يجب أن يبقى نقياً — الأثر الجانبي يحتاج بابه الخاص

جسم دالّة المكوّن يُستدعى بلا أي ضمانةٍ عن عدد المرّات أو التوقيت — React حرٌّ في استدعائه مرّةً، أو مرّتين (سترى لماذا بعد قليل)، أو تجاهل نتيجته إن قاطعه عملٌ أهم. لهذا يجب أن يبقى نقياً: يصف الواجهة فقط، لا يفعل أشياء في العالم الخارجي (شبكة، مؤقّتات، اشتراكات، document.title، localStorage). أي كودٍ كهذا يحتاج باباً منفصلاً، ينفّذه React بعد أن يستقرّ الرسم فعلياً على الشاشة، بضبطٍ صريح لمتى:

useEffect(() => {
  fetch(`/api/users/${userId}`)
    .then((res) => res.json())
    .then((data: UserData) => setUser(data));
}, [userId]);                                       // ← "متى" بالضبط

useEffect يأخذ دالّةً (الأثر نفسه) ومصفوفة تبعيات. الفكرة المركزية:

الأثر لا يُزامَن مع "الرسم"، يُزامَن مع مصفوفة التبعيات. بعد كل رسمٍ، React يقارن قيم مصفوفة التبعيات بقيمها في الرسم السابق — نفس فكرة المقارنة (===) على كل عنصر، تماماً كمقارنة الإقليم ٠ لعقدتين، لكن هنا على قيمٍ لا عقد DOM. لو كلها متطابقة، الأثر لا يُعاد تنفيذه إطلاقاً. لو أيّ عنصرٍ اختلف، يُعاد تنفيذه كاملاً.

[] (مصفوفة فارغة) تعني "لا تبعيات أبداً تتغيّر" → الأثر يُنفَّذ مرّةً واحدة فقط، بعد أول رسم. [userId] تعني "أعد التنفيذ فقط إذا تغيّر userId تحديداً" — لا عند تغيّر أي حالةٍ أخرى في المكوّن. بلا مصفوفة إطلاقاً (لا تكتبها أصلاً) تعني "بعد كل رسمٍ بلا استثناء" — نادراً ما تريد هذا.

كيف: دورة الحياة الفعلية

رسمٌ (جسم الدالّة يُنفَّذ، ينتج وصفاً) │ ▼ التصالح يُحدّث DOM الحقيقي (كما في الإقليم ٠) │ ▼ المتصفّح يرسم الشاشة فعلياً للمستخدم │ ▼ *بعد ذلك* — React يقارن تبعيات كل useEffect بالرسم السابق │ ├─ تطابقت كلّها؟ ← لا شيء يحدث └─ اختلف عنصر؟ ← نفّذ الأثر الآن

مصفوفة التبعيات ليست تحسين أداء اختيارياً — هي عقد صحّة. لو أثرٌ يقرأ userId داخل جسمه ولم تذكره في التبعيات، الأثر سيستمر يستخدم القيمة المحبوسة في إغلاق الرسم الذي كُتب فيه (تماماً كمشكلة count المحبوس في TripleClick بالإقليم ٢) — أي سيبقى يجلب بيانات المستخدم الأول حتى لو تغيّر userId عشر مرّات بعده، لأن الإغلاق لا يعرف بالتغيير. هذا يُسمّى إغلاقٌ عالقٌ (stale closure)، وهو نفس آلية الإقليم ٢ بالضبط، في ثوبٍ جديد.

اللغز/البناء من الصفر — الجزء الأول

اللغز أ. أصلح UserProfile أعلاه بـuseEffect ومصفوفة تبعيات صحيحة. تحقّق في Network: طلبٌ واحدٌ فقط عند أول ظهور. أضف زرّاً يُغيّر userId — تحقّق: طلبٌ جديدٌ واحد بالضبط لكل تغيير، لا أكثر ولا أقل.

اللغز ب — أنتِج إغلاقاً عالقاً عمداً. اكتب مكوّناً فيه حالة query: string (حقل بحث) وأثرٌ يطبع query في الـconsole كل ٣ ثوانٍ عبر setInterval، لكن اجعل مصفوفة التبعيات فارغة ([]) عمداً رغم أن الأثر يقرأ query. اكتب في الحقل قيماً مختلفة بسرعة. راقب: ماذا تطبع الـconsole كل ٣ ثوانٍ — القيمة الحالية أم قيمة اللحظة التي بدأ فيها الأثر؟ فسّر بجملة تربط ما رأيتَه بمفهوم الإغلاق تحديداً. ثم أصلحها بإضافة query لمصفوفة التبعيات، وفسّر لماذا يتطلّب هذا أيضاً تنظيف المؤقّت القديم (الفقرة القادمة) قبل أن يعمل بشكلٍ صحيح تماماً.

ليش وكيف: التنظيف (cleanup) — طاقمٌ يرتّب المسرح قبل المشهد التالي

كل مرّة يُعاد فيها تنفيذ أثرٍ (تبعياته تغيّرت)، أو يغادر الممثل المسرح نهائياً (unmount)، React ينفّذ أولاً دالّة التنظيف التي يُعيدها الأثر — إن أعاد شيئاً:

useEffect(() => {
  const id = setInterval(() => console.log(query), 3000);
  return () => clearInterval(id);          // التنظيف: أوقف المؤقّت القديم
}, [query]);

لماذا هذا إلزامي، لا اختياري: بدون التنظيف، كل مرّة يتغيّر فيها query تُنشئ مؤقّتاً جديداً فوق المؤقّتات القديمة التي لا تزال تعمل — بعد ١٠ تعديلات على حقل البحث، عندك ١٠ مؤقّتات تطبع كل ٣ ثوانٍ، لا واحد. هذا نفس شذوذ "نسخٍ متراكمة لم تُنظَّف" الذي رأيته في الإقليم ٠، لكن في الزمن لا في DOM.

متى تُنفَّذ دالّة التنظيف بالضبط؟

رسمٌ ١ (تبعية = "a")
الأثر يعمل، ينشئ الاشتراك
رسمٌ ٢ (تبعية = "b")
*أولاً* نظّف اشتراك الرسم ١ → ثم شغّل الأثر بتبعية "b"
رسمٌ ٣ (تبعية = "b")
لا تغيير → لا تنظيف، لا تشغيل جديد
المكوّن يُزال من الشجرة → نظّف اشتراك الرسم ٣ (الأخير) نهائياً

كل "تشغيل" للأثر له تنظيفه الخاص المرتبط به، لا تنظيفٌ عامٌّ واحد.

ليش: سباق التسابق — حين يتأخّر الردّ عن موعده

الإغلاق العالق شيء؛ ترتيب الوصول شيءٌ آخر تماماً، وأخطر. تخيّل: userId يتغيّر بسرعة من "1" إلى "2". الأثر يعمل مرّتين: طلبٌ لـ"1"، ثم طلبٌ لـ"2". الشبكة لا تضمن ترتيب الوصول بنفس ترتيب الإرسال — ماذا لو ردّ "1" (البطيء) وصل بعد ردّ "2" (السريع)؟

أُرسِل: طلب "1" ────────────────────────► يصل متأخّراً أُرسِل: طلب "2" ──► يصل بسرعة، setUser(بيانات ٢) ✓ صحيح مؤقتاً طلب "1" يصل الآن، setUser(بيانات ١) ✗ النتيجة النهائية المعروضة: بيانات المستخدم "1" رغم أن props.userId = "2"

الشاشة تعرض بيانات خاطئة رغم أن كل سطر كودٍ "صحيح" منطقياً — هذا سباق تسابق (race condition): نتيجةٌ تعتمد على ترتيب وصولٍ غير مضمون.

اللغز/البناء من الصفر — الجزء الثاني: افتعِل السباق وأصلحه

استخدم محاكياً حتمياً (لا شبكة حقيقية غير متوقّعة) لتضمن إعادة إنتاج السباق في كل مرّة:

function fakeFetchUser(id: string): Promise<UserData> {
  const delay = id === "1" ? 400 : 50;    // "1" دائماً أبطأ من "2"
  return new Promise((resolve) =>
    setTimeout(() => resolve({ id, name: `مستخدم ${id}` }), delay),
  );
}

اللغز ج. ابنِ UserProfile يستخدم fakeFetchUser، بدأً بـuserId="1". أضف زرّاً يُغيّر userId إلى "2" بعد لحظة (محاكياً تبديلاً سريعاً بين مستخدمين، مثل نقر متسرّع بين تبويبين). بلا أي حماية بعد. راقب: ستنتهي الشاشة بعرض بيانات "1" رغم أن userId الفعلي "2" — أنتجتَ السباق بنجاح. الآن أصلحه: أضف علماً محلياً داخل كل تشغيلٍ للأثر، يُغلَق عليه إغلاق ذلك التشغيل تحديداً، ويُبطَّل عبر دالّة التنظيف:

useEffect(() => {
  let ignore = false;                              // خاصٌّ بهذا التشغيل فقط

  fakeFetchUser(userId).then((data) => {
    if (!ignore) setUser(data);                     // تجاهل لو صرت قديماً
  });

  return () => { ignore = true; };                  // التنظيف: أبطل هذا التشغيل
}, [userId]);

تتبّع بنفسك لماذا يعمل هذا بالضبط: كل استدعاءٍ للأثر يُنشئ متغيّر ignore جديداً ومستقلاً (إغلاقٌ جديد، كما في كل شيء تعلّمته عن الإغلاقات حتى الآن). حين يتغيّر userId لـ"2"، React ينظّف تشغيل "1" أولاً — يضبط ignore الخاص بذلك التشغيل إلى true. حين يصل ردّ "1" متأخراً، الدالّة المُغلَق عليها ترى ignore === true (نسختها هي، لا نسخة التشغيل الجديد) وترفض الكتابة. تحقّق: كرّر تبديل userId بسرعة عدّة مرّات؛ يجب أن تعرض الشاشة دائماً بيانات آخر userId طلبتَه، بلا استثناء.

أدقّ من العلم اليدوي: AbortController

العلم اليدوي يمنع الكتابة بالنتيجة المتأخرة، لكن الطلب نفسه لا يزال يُرسَل ويكتمل في الشبكة — مجرد نتيجته تُرمى. AbortController يُلغي الطلب فعلياً (المتصفّح يوقف الاتصال):

useEffect(() => {
  const controller = new AbortController();

  fetch(`/api/users/${userId}`, { signal: controller.signal })
    .then((res) => res.json())
    .then(setUser)
    .catch((err) => {
      if (err.name !== "AbortError") throw err;      // تجاهل الإلغاء المتعمَّد فقط
    });

  return () => controller.abort();                    // ألغِ الطلب فعلياً، لا تتجاهله فقط
}, [userId]);

نفس الفكرة (تنظيفٌ يُبطل تشغيلاً قديماً)، لكن الإبطال حقيقيٌّ على مستوى الشبكة لا مجرّد تجاهلٍ منطقي. اعتبر العلم اليدوي أداةً تفهم بها الآلية؛ واعتبر AbortController الأداة التي تستعملها حين يكون مصدر البيانات fetch فعلياً.

ليش: StrictMode يشغّل كل شيء مرّتين — عمداً

لو رأيت مكوّناتك تُرسَم مرّتين، أو أثراً يعمل ثم يُنظَّف ثم يعمل مجدداً، فوراً بلا أي تفاعل مستخدم — هذا على الأرجح <React.StrictMode> (كما في main.tsx لمشروعكم). في وضع التطوير فقط، React عمداً: يرسم مرّتين، وينفّذ كل أثرٍ بترتيب: تشغيل → تنظيف → تشغيل فوراً عند أول ظهور.

لماذا يفعل هذا بالله؟ لأنه اختبارٌ مجّانيّ لسؤالٍ حرج: *لو نُفِّذ الرسم أو الأثر مرّتين، هل يبقى السلوك النهائي المرئي للمستخدم مطابقاً لما لو نُفِّذ مرّة واحدة؟* رسمٌ نقيّ حقاً لا يتأثّر بإعادة الاستدعاء (لا يفعل شيئاً في العالم الخارجي). وأثرٌ منظَّف بشكل صحيح تماماً — تنظيفه يُبطل كل ما فعله تماماً — يجب أن يكون تشغيله-تنظيفه-تشغيله مرّةً واحدة غير مرئي للمستخدم، مطابقاً تماماً لتشغيلٍ واحد فقط. لو رأيت أثراً جانبياً مضاعفاً حقيقياً (طلبٌ مزدوجٌ ظاهر، اشتراكٌ مضاعَف)، فهذا ليس "علّة React" — هذا React يخبرك بدقّة: تنظيفك ناقص.

اللغز/البناء من الصفر — الجزء الثالث: استغلّ StrictMode كأداة تشخيص

اللغز د. ابنِ "ناشر أحداثٍ" مصغّراً بلا React (EventTarget الحقيقي في المتصفّح يكفي، أو صفّ بسيط بـMap من مستمعين). اكتب مكوّناً يشترك فيه عبر أثرٍ بلا دالّة تنظيف (تعمّد الخطأ):

useEffect(() => {
  emitter.addEventListener("tick", handleTick);
  // ⚠️ لا يوجد return هنا — لا تنظيف
}, []);

شغّله تحت <React.StrictMode>، أطلق حدث "tick" واحداً يدوياً من الخارج (زرّ منفصل خارج المكوّن)، وعُدّ كم مرّة نُفِّذ handleTick. يجب أن تراها مرّتين لحدثٍ واحد — أثر اشتراكٍ مضاعف حقيقي وملموس، بفضل الاشتراك المزدوج غير المُنظَّف من أول رسمَين في StrictMode. الآن أضف دالّة التنظيف الصحيحة (removeEventListener)، وكرّر التجربة: يجب أن تعود لمرّة واحدة بالضبط، رغم أن React لا يزال يُشغّل الأثر مرّتين داخلياً — لأن التنظيف الآن يُبطل الاشتراك الأول فعلياً قبل إنشاء الثاني.

لا تنتقل قبل أن ترى العدّ ينتقل من ٢ إلى ١ في اللغز د بعينك. هذه اللحظة تحديداً هي ما يجعل StrictMode أداة تشخيص، لا مصدر ارتباك.

الخلاصة — أين تتّصل هذه العقدة بالشجرة

الخلاصة
  • ما بنيتَه: فهماً دقيقاً لـuseEffect كـ"مُزامِنٍ مع تبعيات" لا "دورة حياة" (mount/update/unmount كتصنيفٍ قديم مضلِّل)؛ تنظيفاً يُبطل كل تشغيلٍ قبل التالي أو عند المغادرة؛ سباق تسابقٍ حقيقي شخّصته وأصلحته بعلمٍ محليّ ثم بـAbortController؛ وStrictMode كأداة كشفٍ متعمّدة، لا عطلاً.
  • العقدة الجديدة: *المزامنة مع الخارج، بمعزلٍ زمنيٍّ صريح عن الرسم النقي.* هذا يكمل الثنائي المركزي في React: الرسمُ يصف، والأثرُ يزامن.
  • بابٌ مفتوح متعمَّد: كل مثال هنا كان "نفّذ شيئاً حين تتغيّر بيانات خارجية". لكن ماذا عن العكس تماماً — استجابةٌ مباشرة لضغطة مستخدم، لا علاقة لها بأي تبعية تتغيّر بذاتها؟ خلطهما نفس الخطأ بثوبٍ مختلف. → الإقليم ٤.

الوصلة بمشروعك

الوصلة بمشروعك

افتح ذهنياً (لا حرفياً وجوباً) نمط AuthContext في مشروعكم: عند بدء التطبيق، أثرٌ يحاول استرجاع الجلسة (refreshSession)، ويستعمل تحديداً علماً محلياً اسمه isMountedنفس الآلية بالضبط التي بنيتَها في اللغز ج، بنفس الغاية: لو غادر المستخدم الصفحة أو أُعيد تشغيل الأثر قبل وصول الردّ، لا تكتب حالةً على مكوّنٍ لم يعد "حاضراً" منطقياً بنفس السياق. والمشروع يستعمل <React.StrictMode> في main.tsx — فأي أثرٍ فيه بلا تنظيفٍ كافٍ سيُظهر أعراضاً مضاعفة بالضبط كما رأيتَ في اللغز د، في وضع التطوير تحديداً.

البذرة التالية: بنيتَ حتى الآن أثراً واحداً نموذجياً: "بيانات تغيّرت → اجلب من الخادم". لكن نموذج تسجيل الدخول لا يعمل هكذا إطلاقاً — لا يوجد "بيانات تغيّرت تلقائياً"، يوجد نقرة مستخدمٍ واحدة ومباشرة. لو استعملتَ useEffect لهذا أيضاً، ستكتب كوداً "صحيحاً" ظاهرياً يخفي عطلاً خطيراً لن يظهر إلا في أسوأ لحظة.