الإقليم ٠٢ — دفتر الممثل
كل مكوّن بنيتَه حتى الآن بلا ذاكرة: يُستدعى، يقرأ props، يُنتج وصفاً، ينسى كل شيء. هذا الإقليم يعطيه دفتر ملاحظاتٍ خاصاً — وسيكشف لك تفصيلاً داخلياً محرجاً عن كيف يعمل React فعلاً، تفصيلٌ يفسّر قاعدةً غامضة سمعتَ بها لو سبق ولمحتَ React من بعيد: "لا تنادِ الـhooks داخل شرط أو حلقة".
يبني فوق: المكوّن كدالّة تُستدعى من جديد في كل رسم (١)، والمطابقة بالهوية/المفتاح (٠). النطاق: من أين "يتذكّر" مكوّنٌ شيئاً بين عرضٍ وعرض، وكيف يُطلق إعادة رسمٍ لنفسه بلا نداءٍ خارجي. يفتح: كل ما تبقّى من هذا المنهج — هذا أهمّ إقليمٍ فيه.
اللغز المستفزّ
ابنِ عدّاداً بسيطاً: رقمٌ يبدأ من صفر، وزرٌّ يزيده واحداً في كل نقرة.
محاولة ١ — متغيّرٌ محليّ داخل الدالّة:
function Counter(): VNode {
let count = 0;
return h("button", { onClick: () => { count++; console.log(count); } },
String(count),
);
}
انقر الزر عشر مرّات. الـconsole.log يطبع ١، ٢، ٣... يزيد فعلاً! لكن الرقم المرسوم على الزر نفسه لا يتغيّر أبداً — يبقى "٠" مهما نقرت. لماذا؟ لأن النقر لا يُعيد استدعاء Counter() من جديد (لا شيء يطلب من المطابِق إعادة الرسم)؛ فقط يُنفّذ count++ على متغيّرٍ محليٍّ ميت — عاش لحظة الرسم الأولى وانتهى، لا علاقة له بما يظهر على الشاشة الآن.
محاولة ٢ — نادِ إعادة الرسم يدوياً بعد كل نقرة (كما فعلنا في الإقليم ٠):
function Counter(): VNode {
let count = 0; // ⚠️
return h("button", { onClick: () => { count++; rerender(); } },
String(count),
);
}
جرّبها. النتيجة: لا يزال الرقم عالقاً على "١" دائماً، لا يتجاوزه أبداً! لماذا؟ لأن rerender() تستدعي Counter() من جديد بالكامل — وكل استدعاءٍ جديد للدالّة يُنشئ count من الصفر (let count = 0، سطرٌ جديد يُنفَّذ من أوله). النقرة تزيده لـ١ مؤقتاً، rerender() تعيد بناء الزر بمتغيّرٍ محليٍّ طازجٍ يبدأ من صفر... لا، انتظر: النقرة تسبق rerender()، فتصير القيمة المعروضة "١" لحظياً قبل أن تُمحى في المرّة القادمة. جرّب بنفسك واطبع count داخل جسم Counter مباشرة (لا داخل onClick) وراقب: يبدأ من صفر في كل استدعاء، دائماً.
السؤال المستفزّ: الدالّة تُستدعى من جديد بالكامل في كل رسم (هذا ما تعلّمناه في الإقليم ١، وهو صحيحٌ ومطلوب — لا نريد تغييره). فأين، فيزيائياً، يمكن أن "يعيش" رقمٌ يتراكم عبر استدعاءاتٍ متعددة لنفس الدالّة، دون أن يكون متغيّراً عاماً (global) يتشاركه كل عدّاد في الصفحة بالخطأ؟
جرّب حلاً ثالثاً بنفسك قبل المتابعة: متغيّرٌ خارج الدالّة (let count = 0; في أعلى الملف). سيعمل العداد! لكن ضع عدّادين <Counter/> جنباً إلى جنب. انقر أحدهما. الاثنان يتحرّكان معاً — لأنهما يتشاركان نفس المتغيّر الوحيد. هذا ليس "ذاكرة لكل ممثل"؛ هذا سبّورة مشتركة بين كل الممثلين. المطلوب أدقّ: ذاكرةٌ تبقى بين العروض، لكنها خاصة بكل نسخة (instance) من المكوّن على حدة.
ليش: الذاكرة تعيش خارج الدالّة، في سجلٍّ يخصّ كل نسخة
الحلّ: سجلٌّ خارجيٌّ دائم لكل نسخة مكوّن، منفصلٌ عن جسم الدالّة نفسه، يبقى حياً بين الاستدعاءات المتتالية لنفس تلك النسخة تحديداً. في React الحقيقي يُسمّى هذا السجلّ fiber — عقدةٌ واحدة لكل نسخة مكوّنٍ حيّة على الشاشة، تحمل (من بين أشياء أخرى) قائمةً من "خانات الذاكرة" الخاصة بذلك المكوّن. الدالّة نفسها لا تزال بلا ذاكرة، تُستدعى من الصفر كل مرة — لكنها الآن تُعطى مقبضاً للوصول لسجلّها الخاص عند كل استدعاء.
useState هو ذلك المقبض. لا يُنشئ الذاكرة داخل الدالّة — يقرأ ويكتب في خانةٍ مخصّصة له داخل fiber النسخة الحالية.
كيف: ابنِ useState بيدك — عشرة أسطر تشرح كل شيء
لنبسّط أولاً بعالمٍ فيه نسخة مكوّنٍ واحدة نشطة (عدّاد وحيد، لا اثنان بعد). سجلّها الوحيد: مصفوفة ومؤشّر:
let hookStates: unknown[] = [];
let hookIndex = 0;
function useStateNano<T>(initial: T): [T, (next: T) => void] {
const slot = hookIndex; // "خانتي" في هذا النداء تحديداً
if (!(slot in hookStates)) {
hookStates[slot] = initial; // أوّل مرّة فقط: ازرع القيمة الابتدائية
}
const setState = (next: T): void => {
hookStates[slot] = next; // اكتب في نفس الخانة، أياً كانت
scheduleRerender(); // اطلب استدعاءً جديداً للدالّة
};
hookIndex++; // الخانة التالية لنداء useState التالي
return [hookStates[slot] as T, setState];
}
والدالّة المسؤولة عن الرسم تُصفّر المؤشّر قبل كل استدعاء، لا بعده:
function renderRoot(): void {
hookIndex = 0; // ابدأ من أول خانة، كل مرة
const tree = Counter();
patch(rootDom, currentTree, tree, container);
currentTree = tree;
}
جرّب الآن:
function Counter(): VNode {
const [count, setCount] = useStateNano(0);
return h("button", { onClick: () => setCount(count + 1) }, String(count));
}
يعمل! لماذا؟ تتبّع بيدك: النداء الأول لـuseStateNano(0) يقع في الخانة ٠، يزرع 0. تنقر: setCount(1) يكتب 1 في الخانة ٠ ويطلب رسماً جديداً. renderRoot يُصفّر hookIndex لصفر وينادي Counter() من جديد. useStateNano(0) يُنادى مجدداً — نفس الخانة ٠ بالضبط (لأن hookIndex بدأ من صفر مرّة أخرى) — يجدها موجودة مسبقاً (0 in hookStates)، فيتجاهل القيمة الابتدائية 0 المُمرَّرة، ويُرجع القيمة المخزَّنة: 1. هذا بالضبط لماذا الوسيط الذي تُعطيه لـuseState يُستخدَم مرّةً واحدة فقط (أول رسم) ويُتجاهَل في كل مرّة بعدها — والآن تعرف السبب الآلي الدقيق، لا مجرّد الحقيقة.
لماذا زوجٌ (Tuple) لا كائن؟
لاحظ أن useStateNano تُرجع [value, setter]، لا {value, setter}. زوجٌ موضعي يسمح لك تُسمّي كل طرفٍ كما تشاء عبر الهدم الموضعي (destructuring): const [email, setEmail] = ... ثم const [password, setPassword] = ... — لو كان كائناً، كنتَ ستُضطرّ لإعادة تسمية كل حقلٍ يدوياً في كل نداء (const {value: email, setValue: setEmail} = ...). قرارٌ تصميميٌّ صغير، لكنه يوفّر عليك سطراً في كل استدعاء — ولأنك تعرف الفرق بين هدم المصفوفات (بالموضع) وهدم الكائنات (بالاسم) مسبقاً من TypeScript، هذا يجب أن يكون بديهياً الآن.
اللغز/البناء من الصفر — الجزء الأول: عدّادان مستقلّان
اللغز أ. وسّع useStateNano/renderRoot لتدعم نسخَتين مستقلّتين من Counter جنباً إلى جنب، بذاكرةٍ منفصلة تماماً لكل واحدة. الأداة: بدل مصفوفة ومؤشّر عامّين، ابنِ كائن Fiber لكل نسخة:
type Fiber = {
hookStates: unknown[];
hookIndex: number;
type: ComponentFn;
props: Record<string, unknown>;
};
ومؤشّراً واحداً لأي fiber نُرسَم الآن (currentFiber)، يُضبَط قبل نداء دالّة المكوّن ويُستعاد بعده:
let currentFiber: Fiber | null = null;
function renderFiber(fiber: Fiber): Child {
currentFiber = fiber;
fiber.hookIndex = 0; // صفّر مؤشّر *هذا الـfiber تحديداً*
const output = fiber.type(fiber.props);
currentFiber = null;
return output;
}
والآن useStateNano يقرأ/يكتب في currentFiber.hookStates، لا في مصفوفة عامّة واحدة. أنشئ فيبرين يدوياً لعدّادين، وناد renderFiber على كل واحد بدوره عند إعادة الرسم. معيار النجاح: انقر العدّاد الأول خمس مرّات، ثم الثاني مرّتين. يجب أن يعرضا "٥" و"٢" بلا أي تأثير متبادل.
اللغز ب — من أين يأتي الـfiber أصلاً؟ (وصلة بالإقليم ٠) حين يُعاد رسم الأب (الذي يحوي العدّادين)، كيف "يعرف" النظام أن عنصر <Counter/> الأول في الشجرة الجديدة هو نفس النسخة (نفس الـfiber، بذاكرته المتراكمة) التي كانت في المكان الأول سابقاً، لا نسخة جديدة صفرية؟ الجواب هو بالضبط منطق المطابقة الذي بنيتَه في الإقليم ٠: بالموضع افتراضياً، وبالمفتاح (key) إن وُجد في قائمة. طبّق هذا فعلياً: اجعل الأب يرسم مصفوفة ديناميكية من عدّادات (counters.map(id => <Counter key={id} .../>))، واحذف عدّاداً من المنتصف. تحقّق: هل تبقى ذاكرة (القيم المعدودة) العدّادات المتبقية سليمة تماماً وتابعة للـid الصحيح، أم "تقفز" بينها كما حدث في مشكلة الإقليم ٠ بلا مفاتيح؟ اربط ملاحظتك صراحةً بما بنيتَه هناك — هذا نفس الدرس، على مستوى الذاكرة لا مستوى DOM فقط.
ليش: هنا تُشتقّ "قواعد الـhooks" — لا تُحفظ
الآن — وبدون أي حفظٍ إضافي — جاوب بنفسك: ماذا يحدث لو كتبتَ:
function Weird(): VNode {
const [a, setA] = useStateNano(1);
if (a > 5) {
const [b, setB] = useStateNano(100); // ⚠️ نداءٌ شرطي
}
const [c, setC] = useStateNano(3);
// ...
}
في أول رسمٍ (a = 1)، الشرط كاذب: تُستدعى useStateNano مرّتين فقط — a في الخانة ٠، c في الخانة ١. لو صار a > 5 لاحقاً، الشرط يصير صادقاً: الآن تُستدعى ثلاث مرّات — a في الخانة ٠، b في الخانة ١ (الخانة التي كانت تخصّ c سابقاً!)، وc في الخانة ٢ (خانة جديدة، ستُهيَّأ من جديد بقيمتها الابتدائية، ناسيةً أي قيمة سابقة). c فقدت ذاكرتها بصمت، وb ورثت خانة c القديمة بقيمةٍ غريبة. لا استثناء يُرمى. فقط سلوكٌ خاطئ يبدو تصادفياً، صعب التصحيح جداً لأنه يظهر فقط تحت شروطٍ معينة.
هذا تحديداً لماذا القاعدة صارمة: *نادِ الـhooks دائماً بنفس العدد ونفس الترتيب، بلا شروطٍ ولا حلقات ولا نداءات متداخلة.* ليست القاعدة اصطلاحاً جمالياً يفرضه فريق React — هي نتيجة رياضية مباشرة لكون الذاكرة مربوطة بالموضع (index) لا بالاسم. أنت الآن لا تحفظ هذه القاعدة، أنت اشتققتها من تطبيقٍ بنيته بيدك، وتقدر تشرح بالضبط أي خانة تُصاب متى ولماذا في أي مثال يُعطى لك مستقبلاً.
القاعدة الثانية ("نادِ الـhooks فقط من مكوّنات React أو من hooks مخصّصة") لها نفس التفسير: useStateNano تفترض وجود currentFiber غير فارغ (نداءٌ جارٍ لدالّة مكوّن). نادِها من زرّ حدثٍ عادي أو دالّة مساعدة عشوائية خارج أي رسم، وcurrentFiber سيكون null — لا يوجد سجلٌّ لتكتب فيه أصلاً.
كيف: من نسختنا المصغّرة إلى useState الحقيقي
استورد الآن الحقيقي وقارن بصمت:
import { useState } from "react";
function Counter() {
const [count, setCount] = useState(0);
return <button onClick={() => setCount(count + 1)}>{count}</button>;
}
نفس التوقيع بالضبط، نفس السلوك بالضبط. الفرق أن fiber الحقيقي في React ليس مصفوفة بسيطة — هو قائمةٌ مرتبطة (linked list) حقيقية من عقد hooks، وآلية الرسم أعقد بكثير (مجدولة، قابلة للمقاطعة والاستئناف، بأولويات). لكن الجوهر الذي بنيتَه بيدك صحيحٌ حرفياً: خانةٌ لكل نداء hook، بالترتيب، معاد تصفيرها موضعياً قبل كل رسم لنفس الـfiber.
أثرٌ جانبيٌّ مهم: التحديثات الدالّية (functional updates)
جرّب بنفسك، بـuseState الحقيقي هذه المرّة:
function TripleClick() {
const [count, setCount] = useState(0);
const handleClick = () => {
setCount(count + 1);
setCount(count + 1);
setCount(count + 1);
};
return <button onClick={handleClick}>{count}</button>;
}
توقّع بنفسك قبل التشغيل: كم سيزيد العدّاد بعد نقرةٍ واحدة؟ ثم شغّلها. النتيجة ١ فقط، لا ٣. السبب: الرسم الذي بُني فيه handleClick أغلق (closure) على قيمة count كما كانت لحظة ذلك الرسم بالذات — الثلاث نداءات كلها ترى نفس count القديم، فكلها تكتب "القديم + 1" في نفس الخانة، والأخيرة فقط تفوز. الإصلاح: مرّر دالّة بدل قيمة، تستقبل "آخر قيمةٍ معلّقة فعلياً" لا القيمة المحبوسة في الإغلاق:
const handleClick = () => {
setCount((c) => c + 1);
setCount((c) => c + 1);
setCount((c) => c + 1); // كل نداء يبني على نتيجة الذي قبله فعلياً
};
الآن تزيد ٣. لاحظ: هذا ليس تفصيلاً عشوائياً — هو نتيجة مباشرة لكون count داخل handleClick إغلاقٌ على قيمة تلك اللحظة بالذات، لا مرجعاً حياً يتحدّث تلقائياً.
اللغز/البناء من الصفر — الجزء الثاني: تحقّق بيدك
اللغز ج. أعد بناء TripleClick أعلاه بنسختك المصغّرة useStateNano (لا useState الحقيقي). توقّع النتيجة أولاً كتابةً، ثم شغّلها، ثم فسّر بجملة واحدة بالإشارة لكودك أنت (لا شرحاً عاماً) لماذا حصلت على هذه النتيجة بالضبط.
اللغز د — اكسر القاعدة عمداً وشخّص العطب. خذ مكوّناً بثلاثة نداءات useStateNano مرتّبة، اجعل النداء الأوسط شرطياً معتمداً على قيمة حالةٍ أخرى (كما في مثال Weird أعلاه)، بحيث يتغيّر عدد النداءات الفعلي بين رسمٍ وآخر. شغّله، اضغط الزر الذي يُغيّر الشرط، وراقب أي قيمةٍ ظهرت أين. اكتب جملة تشخيصية دقيقة: "الخانة رقم كذا كانت تخصّ المتغيّر كذا، وصارت تخصّ كذا، بسبب كذا" — بالإشارة لأرقام خاناتٍ فعلية من تتبّعك.
لا تنتقل قبل أن تُنتج التشخيص في اللغز د بنفسك من كودك أنت، لا من هذا الشرح. الفرق بين "أعرف القاعدة" و"أعرف لماذا تنكسر" هو بالضبط هذا التمرين.
الخلاصة — أين تتّصل هذه العقدة بالشجرة
- ما بنيتَه: آلية ذاكرةٍ موضعية (
hookIndexكمؤشّر في مصفوفة/قائمة) مربوطة بسجلٍّ دائم لكل نسخة مكوّن (fiber)، تُصفَّر وتُعاد قبل كل رسمٍ لنفس تلك النسخة تحديداً. هوية الـfiber نفسها (أي نسخة تخصّ أي عنصر JSX) تُحسم بنفس منطق المطابقة بالموضع/المفتاح من الإقليم ٠. - العقدة الجديدة: *الذاكرة الموضعية عبر العروض* — أول شيءٍ في هذا المنهج يجعل مكوّناً "يتذكّر" شيئاً خاصاً به، ويُطلق إعادة رسمٍ لنفسه بلا تدخّلٍ خارجي (
setStateتطلب الرسم بنفسها). - قاعدتان اشتُقّتا لا حُفظتا: نداء الـhooks بترتيبٍ وعددٍ ثابتَين (مباشرةً من بنية الخانات الموضعية)، ونداؤها فقط أثناء رسم مكوّنٍ فعلي (مباشرةً من وجود
currentFiber). - بابٌ مفتوح: الذاكرة التي بنيناها تُطلق رسماً جديداً *فوراً* عند كل
setState. لكن ماذا عن أشياء يجب أن تحدث بعد أن يستقرّ الرسم على الشاشة الفعلية — جلب بيانات، الاشتراك في حدثٍ خارجي، ضبط مؤقّت؟ هذه ليست "وصف واجهة"، هي مزامنة مع عالمٍ خارج المسرح. → الإقليم ٣.
الوصلة بمشروعك
كل استدعاء useState في مشروعكم — من const [open, setOpen] = useState(false) في LoginDialog.tsx إلى const [theme, setTheme] = useState<Theme>(...) في theme-provider.tsx — يعتمد على بالضبط الآلية التي بنيتَها هنا: خانةٌ موضعية في fiber ذلك المكوّن بعينه. ولهذا صرامة قاعدة الـhooks ليست تعليمات أسلوب — أي مكوّنٍ في مشروعكم ينادي hook داخل if سيتعطّل بنفس الطريقة بالضبط التي شخّصتها في اللغز د.
البذرة التالية: عندنا الآن ممثلٌ يتذكّر ويعيد تمثيل نفسه بنفسه. لكن كل "تمثيل" حتى الآن نقيّ تماماً — لا يلمس شيئاً خارج المسرح. ماذا لو احتاج الممثل أن يهاتف جهةً خارجية بعد نزول الستار — خادماً، مؤقّتاً، اشتراكاً؟ من ينفّذ هذا، ومتى بالضبط، وماذا لو غادر الممثل المسرح والمكالمة لا تزال جارية؟