كن مطوّر واجهات أمامية محترفا
13 دقيقة قراءة

الحالة والخطّافات في React: من الصفر إلى الاحتراف

دليل عميق وعملي لخطّافات React مع TypeScript — قواعد الخطّافات، وuseState بعمق (التهيئة الكسولة، والتحديثات الدالّية، والحالة كلقطة)، وuseEffect ومصفوفة الاعتماديات (المزامنة مع الأنظمة الخارجية، والتنظيف، ومتى لا تحتاج أثرًا)، وuseRef للقيم وعُقد DOM، وuseMemo وuseCallback، وuseContext للحالة المشتركة، وuseReducer للحالة المعقّدة، وبناء خطّافاتك المخصّصة، والأخطاء الشائعة، مع تمارين عملية وحلولها.

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

الفكرة التي تفتح الخطّافات: كل عرضٍ لقطة. تعمل دالّة المكوّن من أعلى لأسفل في كل عرض، والقيم التي تقرؤها — الخصائص والحالة والمتغيّرات — مجمّدة لـذلك العرض. الخطّافات هي كيف تُبقي React البيانات حيّةً عبر تلك اللقطات وتتيح لك تشغيل كودٍ بينها. أمسِك بذلك، فتتوقّف الإغلاقات القديمة، ومصفوفات الاعتماديات، والتنظيف عن كونها ألغازًا.

ما الخطّاف (والقواعد)

الخطّاف دالّة خاصّة، اسمها يبدأ بـ use...، تتيح للمكوّن أن "يتشبّث" بميزات React مثل الحالة ودورة الحياة. تتعقّب React الخطّافات بترتيب الاستدعاء، ولهذا توجد قاعدتان غير قابلتين للتفاوض:

  1. استدعِ الخطّافات في المستوى الأعلى فقط — لا داخل الشروط أو الحلقات أو الدوال المتداخلة أبدًا. يجب أن يكون ترتيب استدعاءات الخطّافات متطابقًا في كل عرض كي تستطيع React مطابقة كل استدعاءٍ بحالته المخزّنة.
  2. استدعِ الخطّافات من دوال React فقط — المكوّنات أو الخطّافات المخصّصة الأخرى، لا الدوال العادية.
function Profile({ id }: { id: string }) {
  const [user, setUser] = useState<User | null>(null); // ✅ المستوى الأعلى

  if (id) {
    // const [x, setX] = useState(0);  // ❌ خطّاف شرطيّ — يكسر ترتيب الاستدعاء
  }
  // ...
}

يفرض المُدقّق (eslint-plugin-react-hooks) هذه القواعد؛ ثِق به. وإن احتجت منطقًا شرطيًّا، ضع الشرط داخل الخطّاف لا حوله.

useState بعمق

قابلت useState في الأساسيات. وإليك الصورة الكاملة.

const [count, setCount] = useState(0);      // TS تستنتج number
const [user, setUser] = useState<User | null>(null); // صريح حين تبدأ null

الحالة لقطة. داخل عرضٍ واحد، count ثابت. استدعاء setCount لا يغيّر متغيّر count الحاليّ — بل يطلب من React أن تعرض ثانيةً بقيمةٍ جديدة:

function Counter() {
  const [count, setCount] = useState(0);

  function handleClick() {
    setCount(count + 1);
    setCount(count + 1);
    setCount(count + 1);
    // count يساوي 0 طوال هذا العرض، فهذا يضبطه على 1 لا 3
  }
  return <button onClick={handleClick}>{count}</button>;
}

للتحديث بناءً على أحدث قيمة، مرّر مُحدِّثًا دالّيًّا — تطبّقها React بالتتابع:

setCount((c) => c + 1);
setCount((c) => c + 1);
setCount((c) => c + 1);   // الآن هو +3 فعلًا

التهيئة الكسولة. إن كانت الحالة الابتدائية مكلفة الحساب، مرّر دالّة — تستدعيها React في العرض الأول فقط، لا في كل عرض:

const [items, setItems] = useState(() => JSON.parse(localStorage.getItem("items") ?? "[]"));
// لا useState(JSON.parse(...)) — فهذا يشغّل parse في كل عرض

التحديثات مُجمّعة. تُجمَّع عدّة استدعاءات setState في الحدث نفسه في إعادة عرضٍ واحدة، فلا تعتمد على تغيّر الحالة وسط المعالِج — إنها تتحدّث في العرض التالي. وحدّث دائمًا بثباتية: انشُر الكائنات والمصفوفات إلى قيمٍ جديدة بدل تغييرها، وإلا لن تكتشف React التغيير.

useEffect: المزامنة مع العالم الخارجي

يجب أن يكون العرض نقيًّا — يحسب الواجهة ولا شيء غير ذلك. لكن التطبيقات الحقيقية تحتاج أن تفعل أشياء: جلب بيانات، الاشتراك في أحداث، بدء مؤقّتات، تركيز حقل، ضبط عنوان المستند. تلك آثار جانبية، ويشغّلها useEffect بعد العرض، بأمانٍ خارج مرور العرض.

import { useEffect, useState } from "react";

function Clock() {
  const [now, setNow] = useState(() => new Date());

  useEffect(() => {
    const id = setInterval(() => setNow(new Date()), 1000); // الإعداد
    return () => clearInterval(id);                          // التنظيف
  }, []); // مرّة واحدة، عند التركيب

  return <time>{now.toLocaleTimeString()}</time>;
}

جزءان يُعرّفان الأثر:

  • دالّة الأثر تعمل بعد تحديث DOM.
  • دالّة التنظيف الاختيارية التي تُرجِعها تعمل قبل إعادة تشغيل الأثر و عند تفكيك المكوّن. التنظيف هو كيف تُلغي اشتراكًا أو مؤقّتًا أو مستمِعًا كي لا يتسرّب شيء.

مصفوفة الاعتماديات

الوسيط الثاني يتحكّم في متى يُعاد تشغيل الأثر:

useEffect(() => { /* ... */ });          // بعد كل عرض
useEffect(() => { /* ... */ }, []);      // مرّة، بعد العرض الأول (التركيب)
useEffect(() => { /* ... */ }, [userId]); // كلما تغيّر userId

القاعدة: اذكر كل قيمةٍ تفاعلية يقرؤها الأثر — خصائص أو حالة أو قيمًا مشتقّة. تقارن React المصفوفة بين العروض وتعيد تشغيل الأثر حين يتغيّر أيّ عنصر. حذف اعتمادية هو المصدر الأول لعلل الآثار، لأن الأثر يعمل حينئذٍ بقيمةٍ قديمة التُقطت من عرضٍ سابق. دع قاعدة react-hooks/exhaustive-deps ترشدك.

useEffect(() => {
  const controller = new AbortController();
  fetch(`/api/users/${userId}`, { signal: controller.signal })
    .then((r) => r.json())
    .then(setUser);
  return () => controller.abort();  // ألغِ طلبًا قديمًا حين يتغيّر userId
}, [userId]); // أعِد الجلب حين يتغيّر userId؛ وأجهِض السابق

قد لا تحتاج أثرًا

الآثار للمزامنة مع الأنظمة الخارجية. وكثيرٌ من الكود الذي يستخدمها لا ينبغي له. لا تستخدم أثرًا لحساب قيمٍ تستطيع اشتقاقها أثناء العرض، ولا تستخدمه لأشياء تحدث استجابةً لحدث — فذلك المنطق ينتمي إلى معالِج الحدث.

// ❌ أثر لاشتقاق حالة — عرضٌ إضافيّ، وقد يخرج عن التزامن
const [fullName, setFullName] = useState("");
useEffect(() => { setFullName(`${first} ${last}`); }, [first, last]);

// ✅ احسبها أثناء العرض فحسب
const fullName = `${first} ${last}`;

إن أمكن حساب قيمةٍ من خصائص/حالةٍ موجودة، احسبها مباشرةً. ولا تلجأ لأثرٍ إلا حين تمدّ يدك خارج React — الشبكة، DOM، localStorage، اشتراك، مؤقّت.

useRef: قيمٌ تبقى بلا إعادة عرض

يمنحك useRef صندوقًا قابلًا للتغيير ({ current: ... }) يبقى عبر عمليات العرض لكنه لا يُطلِق واحدةً حين تغيّره. استخدامان رئيسيّان.

1. الإشارة إلى عقدة DOM:

function SearchBox() {
  const inputRef = useRef<HTMLInputElement>(null);

  useEffect(() => {
    inputRef.current?.focus(); // التركيز عند التركيب
  }, []);

  return <input ref={inputRef} />;
}

2. تخزين قيمةٍ قابلة للتغيير يجب ألّا تسبّب عرضًا — معرّف مؤقّت، أو القيمة السابقة لشيء، أو راية:

const timerRef = useRef<number | null>(null);
// timerRef.current = setTimeout(...);  // تغييره لا يُعيد العرض أبدًا

التمييز الذي تمسكه: الحالة للقيم التي تعتمد عليها الواجهة (غيّرها ← يُعاد العرض). والمرجع للقيم التي تحتاج تذكّرها لكن الواجهة لا تقرؤها أثناء العرض (غيّره ← لا شيء يُعاد عرضه). إن استخدم العرض قيمةً، فينبغي أن تكون حالة لا مرجعًا.

useMemo وuseCallback: قيمٌ ودوالٌّ ثابتة

كل عرضٍ يُنشئ كائناتٍ ودوالًّا جديدة. عادةً لا بأس بذلك. لكن حالتين تستفيدان من تخزين قيمةٍ عبر العروض:

  • useMemo يخزّن نتيجة حسابٍ مكلف، ويعيد حسابها فقط حين تتغيّر اعتمادياتها.
  • useCallback يخزّن تعريف دالّة، فتبقى هويّتها نفسها عبر العروض.
// أعِد حساب القائمة المفروزة فقط حين يتغيّر items أو sortKey
const sorted = useMemo(
  () => [...items].sort((a, b) => a[sortKey] - b[sortKey]),
  [items, sortKey],
);

// أبقِ هويّة الدالّة نفسها كي لا يُعاد عرض ابنٍ مُحفَّظ
const handleSelect = useCallback((id: string) => {
  setSelected(id);
}, []);

لماذا تهمّ الهويّة: إن مرّرت دالّةً أو كائنًا جديدًا كخاصّةٍ لمكوّنٍ مغلّفٍ بـ React.memo، يُعاد عرضه رغم ذلك لأن الخاصّة "تغيّرت". يُبقي useCallback/useMemo المرجع ثابتًا كي يعمل التحفيظ فعلًا، ويُبقيان القيم ثابتةً حين تُستخدَم في مصفوفة اعتماديات خطّافٍ آخر.

لا تلجأ إليهما افتراضيًّا. فهما يضيفان تعقيدًا وليسا مجّانيَّين. استخدمهما حين يكون لديك حسابٌ مكلفٌ حقًّا، أو حين يلزم مرجعٌ ثابت لـ React.memo أو مصفوفة اعتماديات. التحفيظ المبكّر طريقةٌ شائعة لجعل الكود أصعب قراءةً بلا مكسبٍ ملموس.

useContext: مشاركة الحالة بلا تنقيط

في تدوينة المكوّنات والخصائص رأينا أن تمرير قيمةٍ عبر طبقاتٍ كثيرة (تنقيط الخصائص) رائحةٌ كريهة. يتيح السياق لمزوِّدٍ أن يبثّ قيمةً إلى كل سليل، يقرؤونها مباشرةً بـ useContext — بلا حياكةٍ عبر الوسطاء. مثاليّ للبيانات على مستوى التطبيق حقًّا: المستخدم الحاليّ، السمة، اللغة.

import { createContext, useContext, useState } from "react";

type Theme = "light" | "dark";
type ThemeContextValue = { theme: Theme; toggle: () => void };

// قيمة null افتراضية + خطّاف حارس كي لا ينسى المستهلكون المزوِّد
const ThemeContext = createContext<ThemeContextValue | null>(null);

export function ThemeProvider({ children }: { children: React.ReactNode }) {
  const [theme, setTheme] = useState<Theme>("light");
  const toggle = () => setTheme((t) => (t === "light" ? "dark" : "light"));
  return (
    <ThemeContext.Provider value={{ theme, toggle }}>
      {children}
    </ThemeContext.Provider>
  );
}

// خطّاف مخصّص يضيّق النوع ويرمي خطأً واضحًا عند سوء الاستخدام
export function useTheme() {
  const ctx = useContext(ThemeContext);
  if (!ctx) throw new Error("useTheme must be used inside <ThemeProvider>");
  return ctx;
}

أيّ مكوّنٍ تحت <ThemeProvider> يستدعي const { theme, toggle } = useTheme();. عادتان في TypeScript تجعلان السياق آمنًا: نمِّط القيمة (هنا ThemeContextValue) وغلّف useContext في خطّافٍ مخصّص يرمي حين لا مزوِّد، كي تحصل على قيمةٍ غير فارغة وخطأٍ مفيد بدل undefined صامت.

السياق ليس مجّانيًّا — يُعاد عرض كل مستهلكٍ حين تتغيّر القيمة — فاستخدمه للحالة العالمية بطيئة التغيّر، لا للتحديثات عالية التردّد.

useReducer: منطق حالةٍ يفوق useState

حين تملك الحالة عدّة حقولٍ مترابطة، أو حين تعتمد الحالة التالية على قواعد معقّدة، يكون المُختزِل (reducer) أنظف من التلاعب بعدّة استدعاءات useState. تصِف ما حدث (فعل action) وتحسب دالّة نقية الحالة التالية — نفس نمط Redux، مدمجًا.

type State = { count: number; step: number };
type Action =
  | { type: "increment" }
  | { type: "decrement" }
  | { type: "setStep"; step: number };

function reducer(state: State, action: Action): State {
  switch (action.type) {
    case "increment": return { ...state, count: state.count + state.step };
    case "decrement": return { ...state, count: state.count - state.step };
    case "setStep":   return { ...state, step: action.step };
  }
}

function Counter() {
  const [state, dispatch] = useReducer(reducer, { count: 0, step: 1 });
  return (
    <>
      <output>{state.count}</output>
      <button onClick={() => dispatch({ type: "increment" })}>+{state.step}</button>
    </>
  );
}

تنميط Action كـاتحادٍ مُميَّز يعني أن المُختزِل مُدقَّق النوع بالكامل: كل case يضيّق إلى شكل الفعل الصحيح، ولن يُترجَم إرسال فعلٍ غير صالح. الجأ إلى useReducer حين يتفرّع منطق التحديث أو حين تريد اختبار انتقالات الحالة منعزلةً كدالّةٍ عادية.

الخطّافات المخصّصة: منطقك القابل لإعادة الاستخدام

القوّة الحقيقية للخطّافات هي استخلاص منطق الحالة في دوالٍّ قابلة لإعادة الاستخدام. الخطّاف المخصّص مجرّد دالّةٍ يبدأ اسمها بـ use وتستدعي خطّافاتٍ أخرى. يتيح لمكوّنَين مشاركة سلوك (لا وسوم) — شيءٌ لا تستطيعه المكوّنات وحدها.

// قابل لإعادة الاستخدام: قيمة منطقية تستطيع تبديلها
function useToggle(initial = false) {
  const [on, setOn] = useState(initial);
  const toggle = useCallback(() => setOn((v) => !v), []);
  return [on, toggle] as const; // as const ← tuple مُنمَّط [boolean, () => void]
}

// قابل لإعادة الاستخدام: حالة مُزامَنة مع localStorage
function useLocalStorage<T>(key: string, initial: T) {
  const [value, setValue] = useState<T>(() => {
    const stored = localStorage.getItem(key);
    return stored ? (JSON.parse(stored) as T) : initial;
  });
  useEffect(() => {
    localStorage.setItem(key, JSON.stringify(value));
  }, [key, value]);
  return [value, setValue] as const;
}

// استخدامهما يُقرأ كخطّافاتٍ مدمجة
const [isOpen, toggleOpen] = useToggle();
const [name, setName] = useLocalStorage("name", "");

الخطّافات المخصّصة تتركّب: واحدٌ يستدعي آخر. لا تتشارك الحالة بين المكوّنات (كل استدعاءٍ يحصل على حالته الخاصّة)، بل تتشارك المنطق. حين ترى نمط useState + useEffect نفسه في موضعين، فتلك إشارةٌ لاستخلاص خطّافٍ مخصّص. أرجِع tuple بـ as const (كالمدمجة) أو كائنًا للقيم الكثيرة.

الأخطاء الشائعة

  • كسر قواعد الخطّافات — استدعاء خطّافٍ شرطيًّا أو في حلقة. أبقِ كل خطّافٍ في المستوى الأعلى، بالترتيب نفسه في كل عرض.
  • اعتماديات أثرٍ ناقصة — أثرٌ يقرأ userId لكن اعتمادياته []، فيستخدم قيمةً قديمة. اذكر كل قيمةٍ تفاعلية يقرؤها؛ واتّبع exhaustive-deps.
  • حلقات آثارٍ لا نهائية — أثرٌ يضبط حالةً في مصفوفة اعتمادياته. أزِل الاعتمادية، أو استخدم مُحدِّثًا دالّيًّا، أو انقل المنطق خارج الأثر.
  • أثرٌ لحالةٍ مشتقّة — حساب قيمةٍ من خصائص/حالةٍ عبر useEffect + setState. احسبها أثناء العرض بدلًا من ذلك.
  • أثرٌ لمنطق حدث — كودٌ ينبغي أن يعمل "حين ينقر المستخدم" ينتمي إلى المعالِج، لا إلى أثرٍ يراقب الحالة.
  • قراءة الحالة مباشرةً بعد setState — لن تكون قد تحدّثت بعد؛ القيمة الجديدة تظهر في العرض التالي. استخدم مُحدِّثًا دالّيًّا حين تعتمد القيمة التالية على السابقة.
  • تخزين بياناتٍ تؤثّر في العرض داخل مرجع — لن تتحدّث الواجهة حين تتغيّر. إن قرأها العرض، فهي حالة.
  • الإفراط في useMemo/useCallback — تحفيظ قيمٍ تافهة يضيف ضجيجًا وكلفة. احفظهما للعمل المكلف أو الهويّات الثابتة المطلوبة.
  • نسيان تنظيف الأثر — الاشتراكات والمؤقّتات والمستمِعون يتسرّبون بلا دالّة تنظيفٍ مُرجَعة.

تمارين

جرّب كلًّا قبل فتح الحلّ.

تمرين 1 — أصلِح الزيادة الثلاثية

يُفترَض بهذا المعالِج أن يضيف 3 لكنه يضيف 1 فقط. أصلِحه.

const [n, setN] = useState(0);
function addThree() { setN(n + 1); setN(n + 1); setN(n + 1); }
اعرض الحل
function addThree() {
  setN((c) => c + 1);
  setN((c) => c + 1);
  setN((c) => c + 1);
}

كل مُحدِّثٍ دالّيّ يتلقّى أحدث قيمةٍ معلّقة، فيتراكمون إلى +3. أما الأصل فيقرأ n (لقطةً مثبّتة على 0 لهذا العرض) ثلاث مرّات.

تمرين 2 — أثرٌ بتنظيف

أضِف مستمِع resize يخزّن window.innerWidth في الحالة، ونظّفه عند التفكيك.

اعرض الحل
const [width, setWidth] = useState(() => window.innerWidth);
useEffect(() => {
  const onResize = () => setWidth(window.innerWidth);
  window.addEventListener("resize", onResize);
  return () => window.removeEventListener("resize", onResize);
}, []);

المصفوفة الفارغة تشغّله مرّةً؛ والتنظيف يزيل المستمِع عند التفكيك كي لا يتسرّب أو يُطلَق بعد زوال المكوّن.

تمرين 3 — استخلص خطّافًا مخصّصًا

حوّل منطق التحجيم أعلاه إلى خطّاف useWindowWidth() قابل لإعادة الاستخدام يُرجِع العرض الحاليّ.

اعرض الحل
function useWindowWidth() {
  const [width, setWidth] = useState(() => window.innerWidth);
  useEffect(() => {
    const onResize = () => setWidth(window.innerWidth);
    window.addEventListener("resize", onResize);
    return () => window.removeEventListener("resize", onResize);
  }, []);
  return width;
}
// const width = useWindowWidth();

نفس المنطق، الآن قابلٌ لإعادة الاستخدام في أيّ مكوّن. وكل مكوّنٍ يستدعيه يحصل على حالته المستقلّة — الخطّافات تتشارك المنطق لا الحالة.

تمرين 4 — نمِّط فعل مُختزِل

اكتب نوع Action لمُختزِل مهامّ يدعم إضافة مهمّة (بـ text)، وتبديل واحدة (بـ id)، ومسح الكل.

اعرض الحل
type Action =
  | { type: "add"; text: string }
  | { type: "toggle"; id: string }
  | { type: "clear" };

اتحادٌ مُميَّز على type: كل متغايرٍ يحمل بالضبط الحمولة التي يحتاجها، فيضيّق switch المُختزِل إلى الشكل الصحيح، ويفشل الإرسال غير الصالح في الترجمة.

تمرين 5 — اكتشف العلّة

لماذا يعمل هذا الأثر في كل عرض، وكيف تصلحه؟

useEffect(() => {
  fetchUser(userId).then(setUser);
}, [{ userId }]);
اعرض الحل

الاعتمادية كائنٌ حرفيّ جديد في كل عرض، فـ [{ userId }] لا يساوي السابق أبدًا ويُعاد تشغيل الأثر دائمًا. اعتمِد على القيمة الأوّليّة بدلًا من ذلك:

useEffect(() => {
  fetchUser(userId).then(setUser);
}, [userId]);

تُقارَن مصفوفات الاعتماديات بالمرجع للكائنات/المصفوفات؛ فمرّر قيمًا أوّليّة (أو مراجع ثابتة عبر useMemo) كاعتماديات.

النموذج الذهني الذي تحتفظ به

كل عرضٍ لقطة: يعمل مكوّنك من أعلى لأسفل، والخصائص والحالة والمتغيّرات التي يقرؤها مثبّتةٌ لذلك المرور — والخطّافات هي كيف تحمل React البيانات عبر اللقطات وتشغّل كودًا بينها. useState يحمل القيم التي تعتمد عليها الواجهة؛ حدّثه بثباتيةٍ وبـمُحدِّثٍ دالّيّ حين تعتمد القيمة التالية على السابقة. useEffect يزامن مع العالم الخارجي بعد العرض — اذكر كل قيمةٍ تفاعلية يقرؤها في مصفوفة الاعتماديات، وأرجِع تنظيفًا لإلغاء الاشتراكات والمؤقّتات، ولا تلجأ إليه حين تستطيع اشتقاق قيمةٍ أثناء العرض أو معالجة شيءٍ في حدث. useRef يتذكّر قيمةً قابلة للتغيير (أو عقدة DOM) بلا إعادة عرض. useMemo/useCallback يُبقيان القيم والدوال ثابتةً حين تهمّ تلك الهويّة فعلًا — لا افتراضيًّا. useContext يشارك الحالة العالمية بطيئة التغيّر بلا تنقيط (نمِّطه واحرُسه بخطّافٍ مخصّص)، و**useReducer** ينظّم انتقالات الحالة المعقّدة كدالّةٍ نقية بفعلٍ من اتحادٍ مُميَّز. وأخيرًا، الخطّافات المخصّصة تتيح لك استخلاص وإعادة استخدام أيٍّ من هذا المنطق. أبقِ نموذج "العرض لقطة، والخطّافات تبقى عبر اللقطات" في ذهنك، والتزِم قواعد الخطّافات، فيتوقّف النظام كله عن مفاجأتك.