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

الإقليم ٠٥ — أدواتٌ في جيب الممثل

يبني فوق: الفيبر كسجلّ ذاكرة موضعي (٢)، مصفوفة التبعيات كمقارنةٍ لكل عنصر (٣). النطاق: ثلاث حاجاتٍ لا يحلّها useState جيداً: ذاكرةٌ بلا رسمٍ جديد، هويّةٌ ثابتة عبر الرسوم، وقواعد انتقالٍ مركزية. يفتح: الخطافات المخصّصة (٦).

الجزء الأول: useRef — ذاكرةٌ لا تُعلن عن نفسها

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

تريد أن يُركَّز المؤشّر تلقائياً على حقل البريد الإلكتروني حين يظهر نموذج الدخول. حدسك: "قيمةٌ أحتاج تذكّرها → useState":

function LoginForm() {
  const [inputEl, setInputEl] = useState<HTMLInputElement | null>(null);

  useEffect(() => {
    inputEl?.focus();
  }, [inputEl]);

  return <input ref={/* كيف أصل لعنصر الـDOM أصلاً؟ */} />;
}

تتوقّف فوراً: useState تعطيك مكاناً لتخزين قيمة، لكن من أين تأتي القيمة الأولى — عنصر DOM حقيقي — قبل أن يُبنى ذلك العنصر أصلاً في الشجرة؟ ولماذا تحتاج أصلاً إعادة رسمٍ (الذي يسبّبه setInputEl) فقط لتخزين مرجع عنصرٍ لن يظهر أثره في أي JSX إطلاقاً؟

ليش: ليست كل ذاكرة تحتاج أن "تُعلن" عن نفسها بإعادة رسم

فكّر بدقّة فيما تحتاجه فعلاً: صندوقٌ يبقى بين العروض، تكتب فيه وتقرأ منه، ولا علاقة له بما يُرسَم على الشاشة مباشرة. لا تريد "إعلاناً" (رسماً جديداً) في كل مرّة يتغيّر — تريد فقط مكاناً يتذكّر. useState مُصمَّمة خصيصاً لتُطلق رسماً؛ استعمالها هنا يجلب سلوكاً غير مرغوب (رسمٌ إضافي بلا داعٍ) لحلّ مشكلةٍ لا تحتاجه أصلاً.

كيف: ابنِ useRef بيدك — امتدادٌ لما بنيتَه في الإقليم ٢

نفس سجلّ الفيبر بالضبط، لكن بدون أي طلب رسمٍ جديد، وبكائنٍ واحد يُنشأ مرّة وتُعاد نفس نسخته دائماً:

function useRefNano<T>(initial: T): { current: T } {
  const fiber = currentFiber!;
  const slot = fiber.hookIndex;

  if (!(slot in fiber.hookStates)) {
    fiber.hookStates[slot] = { current: initial };   // كائنٌ واحد، مرّةً واحدة فقط
  }

  fiber.hookIndex++;
  return fiber.hookStates[slot] as { current: T };
}

قارن هذا بـuseStateNano من الإقليم ٢: هناك setState كانت تستبدل القيمة المخزَّنة وتطلب رسماً. هنا لا يوجد setter إطلاقاً — تُرجَع نفس كائن { current } في كل مرّة، وأنت تُعدّل .current مباشرة (تحويرٌ في مكانه، لا استبدال) — ولأن لا أحد يستدعي scheduleRerender عند هذا التعديل، لا شيء يُطلَق. هذا الفرق الوحيد هو كل جوهر useRef: نفس آلية التخزين الموضعي، بلا سلك التنبيه المتصل بالرسم.

كيف: توصيل ref بعنصر DOM حقيقي

ref كخاصية على وسمٍ مضيف (<input ref={...}>) ليست prop عادية — React يتعامل معها بشكل خاص: بعد أن يُنشئ createDom عقدة DOM فعلية، يضبط ref.current لتلك العقدة تحديداً. وسّع createDom:

function createDom(vnode: Child): Node {
  if (typeof vnode === "string") return document.createTextNode(vnode);
  if (typeof vnode.type === "function") return createDom(vnode.type(vnode.props));

  const el = document.createElement(vnode.type);
  for (const [key, value] of Object.entries(vnode.props)) {
    if (key === "ref") continue;                        // لا تعاملها كخاصية DOM عادية
    setProp(el, key, value);
  }
  /* ...الأبناء كما كانت... */

  const ref = vnode.props.ref as { current: unknown } | undefined;
  if (ref) ref.current = el;                             // التوصيل الفعلي هنا

  return el;
}

الآن inputRef.current يشير للعنصر الحقيقي بعد أن يُركَّب (mount)، ويمكنك:

function LoginForm() {
  const inputRef = useRefNano<HTMLInputElement | null>(null);

  useEffect(() => {
    inputRef.current?.focus();          // بعد أن استقرّ الرسم فعلياً (الإقليم ٣)
  }, []);

  return <input ref={inputRef} type="email" />;
}

لاحظ لماذا التركيز يحدث داخل useEffect لا في جسم الدالّة مباشرة: العنصر الحقيقي لا يوجد بعد أثناء الرسم نفسه (لم يُبنَ DOM بعد) — يوجد فقط بعد أن يُطبَّق الرسم فعلياً، وهذا بالضبط توقيت الأثر (الإقليم ٣).

اللغز/البناء من الصفر

اللغز أ. ابنِ useRefNano وربطها بـcreateDom كما أعلاه. أثبت أن تعديل .current لا يُطلق رسماً: ابنِ مكوّناً يعدّ عدد مرّات نداء جسمه (عبر useStateNano منفصل يُطبَع لا يُعرَض)، عدّل ref داخل معالج نقرة عدّة مرّات، وتحقّق أن عدّاد الرسوم لا يتحرّك إطلاقاً رغم النقرات.

اللغز ب — usePrevious: اقرأ "ما كان"، لا "ما هو الآن". ابنِ خطافاً يُرجع قيمة الرسم السابق لمتغيّرٍ ما (مفيدٌ لعرض "تغيّر من كذا إلى كذا"). القيد: استعمل useRefNano + useEffect فقط، بلا useStateNano. تلميح الآلية (لا الحل): ref تقرأه أثناء الرسم يعكس ما كُتب فيه آخر مرّة؛ والأثر ينفَّذ بعد الرسم. رتّب: متى تقرأ من الـref، ومتى تكتب فيه، بحيث تقرأ *قبل* أن تكتب القيمة الجديدة في كل دورة. تحقّق: اعرض "القيمة الحالية: X، السابقة: Y" وغيّر X عدّة مرّات — يجب أن يتأخّر Y بالضبط رسمةً واحدة عن X دائماً.

الجزء الثاني: useMemo وuseCallback — هويّةٌ ثابتة عبر الرسوم

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

عندك قائمة عناصر ثقيلة الرسم (ExpensiveList)، فغلّفتَها بـmemo (أداة جاهزة في React تقارن props الجديدة بالقديمة، وتتخطّى إعادة الرسم كلياً لو تطابقت جميعها بـ===):

const ExpensiveList = memo(function ExpensiveList({
  items,
  onItemClick,
}: {
  items: string[];
  onItemClick: (item: string) => void;
}) {
  console.log("ExpensiveList يُعاد رسمه");
  return <ul>{items.map((i) => <li key={i} onClick={() => onItemClick(i)}>{i}</li>)}</ul>;
});

function Parent() {
  const [count, setCount] = useState(0);
  const items = ["a", "b", "c"];
  const handleClick = (item: string) => console.log(item);

  return (
    <>
      <button onClick={() => setCount(count + 1)}>عدّاد: {count}</button>
      <ExpensiveList items={items} onItemClick={handleClick} />
    </>
  );
}

انقر زر العدّاد (لا علاقة له بالقائمة إطلاقاً). راقب الـconsole: ExpensiveList يُعاد رسمه تُطبَع في كل نقرة، رغم أن memo من المفترض أن يمنع هذا لأن لا شيء "يخصّ" القائمة تغيّر. لماذا فشل memo؟

ليش: === على المصفوفات والدوال ليست كما تتوقّع

items سطرٌ جديدٌ تماماً ["a", "b", "c"] يُنشَأ من جديد في كل استدعاء لـParent — مصفوفةٌ جديدة، بنفس المحتوى، لكن بهويّةٍ مختلفة في الذاكرة. وأنت تعرف من TypeScript/JS مسبقاً: ["a"] === ["a"] نتيجتها false — المقارنة على المصفوفات والكائنات والدوال مرجعية، لا على المحتوى. memo يقارن كل prop بـ=== بالضبط — فيرى items "جديدة" (مرجعياً) في كل مرّة، حتى لو محتواها متطابق حرفياً، ويقرّر أن هناك تغييراً، فيُعيد الرسم. نفس القصة لـhandleClick: دالّةٌ جديدة تُنشأ في كل استدعاءٍ لـParent، فمرجعها يختلف كل مرّة حتى لو جسمها متطابق حرفياً.

كيف: useMemo — كوّن مرّة، أعِد نفس المرجع إن لم يتغيّر شيء

const items = useMemo(() => ["a", "b", "c"], []);            // نفس المرجع دائماً، deps فارغة
const handleClick = useCallback((item: string) => console.log(item), []);

useMemo(compute, deps) يخزّن قيمة محسوبة ومصفوفة تبعياتها؛ في كل رسم يقارن التبعيات الجديدة بالقديمة (نفس آلية المقارنة بالضبط من useEffect — الإقليم ٣) — تطابقت؟ يُرجع نفس المرجع القديم المخزَّن، بلا إعادة حساب. اختلفت؟ يحسب من جديد ويخزّن المرجع الجديد. useCallback(fn, deps) ليست أداة مختلفة جوهرياً — هي حالة خاصة من useMemo حيث القيمة المُخزَّنة هي الدالّة نفسها:

// useCallback(fn, deps) يكافئ تماماً:
useMemo(() => fn, deps);

كيف: ابنِ useMemoNano — نفس فيبر الإقليم ٢ لغرضٍ ثالث

function useMemoNano<T>(compute: () => T, deps: unknown[]): T {
  const fiber = currentFiber!;
  const slot = fiber.hookIndex;
  const prev = fiber.hookStates[slot] as { value: T; deps: unknown[] } | undefined;

  const changed = !prev || deps.length !== prev.deps.length ||
    deps.some((d, i) => d !== prev.deps[i]);

  const value = changed ? compute() : (prev as { value: T }).value;
  fiber.hookStates[slot] = { value, deps };
  fiber.hookIndex++;
  return value;
}

لاحظ: نفس الخانة الموضعية، نفس فكرة المقارنة بالتبعيات التي بنيتَها لـuseEffect. لا آلية جديدة هنا — فقط تطبيقٌ ثالث لنفس النمط (فيبر + خانة + مقارنة تبعيات) الذي أتقنتَه مرّتين من قبل.

ليش لا تستعملها في كل مكان

كل نداء useMemo/useCallback يكلّف شيئاً: تخزين القيمة القديمة، ومقارنة كل عنصر تبعية، في كل رسم. لو الحساب رخيصٌ أصلاً (كما ["a","b","c"] حرفياً) أو لا يوجد memo أسفل السلسلة يستفيد من الهويّة الثابتة، فأنت تدفع تكلفة المقارنة بلا أي فائدة تقابلها. القاعدة: استعملها حين أثبتَّ حاجة فعلية — حسابٌ مكلف فعلاً، أو memo عطّله عدم استقرار الهويّة كما رأيتَ للتو — لا كعادةٍ افتراضية على كل سطر.

اللغز/البناء من الصفر

اللغز ج. أصلح Parent/ExpensiveList أعلاه بـuseMemo/useCallback. تحقّق: انقر زر العدّاد عدّة مرّات، ويجب أن تختفي طباعة "ExpensiveList يُعاد رسمه" تماماً (لا تُطبَع إلا عند تغيّرٍ فعلي في القائمة نفسها).

اللغز د. أعد اللغز ج بـuseMemoNano الذي بنيتَه، بدل الحقيقي، وتحقّق أنه يعطي نفس النتيجة بالضبط.

الجزء الثالث: useReducer — قواعد انتقالٍ في مكانٍ واحد

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

ابنِ معالج خطواتٍ (wizard) بثلاث خطوات لإنشاء منهج: تفاصيل → مراجعة → إرسال. بخمس قطع حالة منفصلة:

const [step, setStep] = useState(1);
const [title, setTitle] = useState("");
const [description, setDescription] = useState("");
const [isSubmitting, setIsSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);

زرّ "التالي" ينادي setStep(step + 1). زرّ "رجوع" ينادي setStep(step - 1). لكن انسَ — كما يفعل أي مبرمج تحت ضغط وقت — أن تتحقّق من isSubmitting قبل السماح بـ"التالي"، أو أن تصفّر error عند "رجوع". النتيجة: يمكن للمستخدم النقر "التالي" أثناء الإرسال فينتقل لخطوةٍ جديدة والطلب لا يزال معلّقاً، أو يرجع خطوة ويظل خطأ الخطوة السابقة معروضاً في سياقٍ غير صحيح. كل قطعة حالة "صحيحة" بمفردها؛ التركيبة الكلية غير متّسقة.

ليش: خمسة setters مستقلّة = خمس نقاط يمكن أن تُنسى فيها القاعدة

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

كيف: مركزة كل الانتقالات في دالّة واحدة

type WizardState = {
  step: 1 | 2 | 3;
  title: string;
  description: string;
  isSubmitting: boolean;
  error: string | null;
};

type WizardAction =
  | { type: "SET_FIELD"; field: "title" | "description"; value: string }
  | { type: "NEXT" }
  | { type: "BACK" }
  | { type: "SUBMIT_START" }
  | { type: "SUBMIT_SUCCESS" }
  | { type: "SUBMIT_ERROR"; error: string };

function wizardReducer(state: WizardState, action: WizardAction): WizardState {
  switch (action.type) {
    case "SET_FIELD":
      return { ...state, [action.field]: action.value };
    case "NEXT":
      if (state.isSubmitting) return state;                        // القاعدة، بمكانٍ واحد
      return { ...state, step: Math.min(state.step + 1, 3) as WizardState["step"] };
    case "BACK":
      if (state.isSubmitting) return state;
      return { ...state, step: Math.max(state.step - 1, 1) as WizardState["step"], error: null };
    case "SUBMIT_START":
      return { ...state, isSubmitting: true, error: null };
    case "SUBMIT_SUCCESS":
      return { ...state, isSubmitting: false };
    case "SUBMIT_ERROR":
      return { ...state, isSubmitting: false, error: action.error };
  }
}

WizardAction اتحادٌ مميَّز (discriminated union) — تعرفه مسبقاً: كل عضوٍ له type مختلف يعمل كعلامةٍ يستدلّ منها TypeScript على بقية الحقول داخل كل case. الآن كل انتقالٍ ممكن يمرّ عبر هذه الدالّة الواحدة — القاعدة "لا تنتقل أثناء الإرسال" مكتوبةٌ مرّةً واحدة، ومُطبَّقة تلقائياً أينما استُدعي dispatch({ type: "NEXT" })، بلا إمكانية نسيانها في مكانٍ ونتذكّرها في آخر.

const [state, dispatch] = useReducer(wizardReducer, initialState);
// ...
<button onClick={() => dispatch({ type: "NEXT" })}>التالي</button>

ليش useReducer ليس أداةً جديدة جوهرياً

// useReducer(reducer, initial) يكافئ تقريباً:
function useReducerNano<S, A>(reducer: (s: S, a: A) => S, initial: S) {
  const [state, setState] = useStateNano(initial);
  const dispatch = (action: A) => setState(reducer(state, action));
  return [state, dispatch] as const;
}

كل ما فعله useReducer هو فرض انضباط: بدل أن تعطي المستهلك حرّية استدعاء setState بأي قيمةٍ عشوائية من أي مكان، تجبره على المرور عبر دالّةٍ نقيّة واحدة تصف *كل* الانتقالات الممكنة. الأداة تحت الغطاء نفسها بالضبط (useState)؛ الفرق تصميميٌّ بحت في الانضباط الذي يفرضه الشكل.

اللغز/البناء من الصفر

اللغز هـ. أعد بناء معالج الخطوات بـuseReducer والاتحاد المميَّز أعلاه. تحقّق: زر "التالي" لا يفعل شيئاً أثناء isSubmitting === true (جرّبه فعلياً بمحاكاة إرسالٍ بطيء عبر setTimeout)، وزر "رجوع" يمسح error دائماً بلا استثناء واحد منسي.

اللغز و. ابنِ useReducerNano كما أعلاه فوق useStateNano الخاص بك من الإقليم ٢ (لا فوق useReducer الحقيقي). شغّل معالج الخطوات عليه وتحقّق من تطابق السلوك.

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

الخلاصة
  • ثلاث أدواتٍ، أساسٌ واحد: useRef (ذاكرةٌ بلا رسمٍ جديد، كائنٌ ثابتٌ يُحوَّر في مكانه)، useMemo/useCallback (ذاكرةٌ لهويّةٍ محسوبة، بنفس آلية مقارنة التبعيات من الإقليم ٣)، useReducer (نفس useState بانضباطٍ انتقالٍ مركزي). كلها امتداداتٌ لنفس سجلّ الفيبر الموضعي الذي بنيتَه في الإقليم ٢ — لا آليات منفصلة.
  • العقدة الجديدة: *أدوات ذاكرة متخصّصة، كلٌّ لغرضٍ لا يخدمه useState الخام جيداً.*
  • بابٌ مفتوح: لاحظ أنك بدأتَ تكرّر نمطاً — حقلٌ + تحقّقٌ + حالة تحميل/ خطأ، أو مرجعٌ + أثرٌ لقراءة "القيمة السابقة". كيف تُغلّف هذا التكرار في اسمٍ واحد قابلٍ لإعادة الاستخدام عبر مكوّنات مختلفة تماماً؟ → الإقليم ٦.

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

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

ModeToggle وعناصر النموذج في RegisterForm.tsx عندكم مرشّحةٌ طبيعية لـ useRef لو احتاجت تركيزاً برمجياً على حقلٍ معيّن بعد خطأ تحقّق. ونظام التصميم (shared/ui/button.tsx) يستعمل forwardRef — آلية تمرير ref عبر طبقة مكوّن كاملة لا عبر وسمٍ واحد مباشرة؛ سنبنيها بيدنا حين نصل لورشة الأزياء (٩)، وهي امتدادٌ مباشر لما بنيتَه هنا. أما useReducer، فأي منطقٍ مستقبلي في curriculum-builder (خطوات إنشاء منهج متعدّدة، بحالاتٍ مترابطة كالتي بنيتَها للتو) مرشّحٌ طبيعي له.

البذرة التالية: كل خطافٍ بنيتَه حتى الآن (state, effect, ref, memo, reducer) عاش داخل مكوّنٍ واحد بعينه. لكن useLoginForm في اللغز الذي بنيتَه بالإقليم ٤ — لو أردتَ نفس الشكل بالضبط (حقول + تحقّق + حالة تحميل) في نموذج آخر كلياً (تسجيل حساب، تعديل ملفٍّ شخصي) — هل تنسخ كل الكود من جديد؟