أساسيات React.js

Custom Hooks - استخراج المنطق القابل لإعادة الاستخدام

18 دقيقة الدرس 15 من 40

فهم Custom Hooks

Custom Hooks هي دوال JavaScript التي تتيح لك استخراج منطق المكون إلى دوال قابلة لإعادة الاستخدام. تسمح لك بمشاركة منطق الحالة بين المكونات دون تغيير تسلسل المكونات أو استخدام خصائص العرض والمكونات عالية الترتيب.

Custom hook هو ببساطة دالة يبدأ اسمها بـ "use" والتي قد تستدعي Hooks أخرى. اتفاقية التسمية هذه مهمة - فهي تتيح لـ React التحقق من أن دالتك تتبع قواعد Hooks.

فوائد Custom Hooks

توفر Custom hooks إعادة استخدام الكود، تنظيم أفضل، اختبار أسهل، تركيب المنطق، ومكونات أنظف. تساعدك على تجنب تكرار المنطق عبر مكونات متعددة.

قواعد Hooks

قبل إنشاء custom hooks، افهم هذه القواعد الأساسية:

  • استدعِ Hooks فقط على المستوى الأعلى: لا تستدعِ Hooks داخل الحلقات أو الشروط أو الدوال المتداخلة
  • استدعِ Hooks فقط من دوال React: استدعها من مكونات دوال React أو Custom Hooks
  • يجب أن تبدأ أسماء Hook بـ "use": هذه الاتفاقية تتيح للأدوات التحقق من اتباع القواعد
// ✅ صحيح: custom hook يتبع القواعد
function useCounter(initialValue = 0) {
  const [count, setCount] = useState(initialValue);

  const increment = () => setCount(c => c + 1);
  const decrement = () => setCount(c => c - 1);
  const reset = () => setCount(initialValue);

  return { count, increment, decrement, reset };
}

// ❌ خطأ: عدم الاستدعاء على المستوى الأعلى
function useCounter() {
  if (condition) {
    const [count, setCount] = useState(0); // ❌ Hook داخل شرط
  }
}

إنشاء أول Custom Hook لك

لنقم بإنشاء custom hook بسيط لإدارة حالة إدخال النموذج:

import { useState } from 'react';

function useInput(initialValue = '') {
  const [value, setValue] = useState(initialValue);

  const handleChange = (e) => {
    setValue(e.target.value);
  };

  const reset = () => {
    setValue(initialValue);
  };

  return {
    value,
    onChange: handleChange,
    reset
  };
}

// الاستخدام في المكون
function LoginForm() {
  const email = useInput('');
  const password = useInput('');

  const handleSubmit = (e) => {
    e.preventDefault();
    console.log('البريد الإلكتروني:', email.value);
    console.log('كلمة المرور:', password.value);
    email.reset();
    password.reset();
  };

  return (
    <form onSubmit={handleSubmit}>
      <input type="email" {...email} placeholder="البريد الإلكتروني" />
      <input type="password" {...password} placeholder="كلمة المرور" />
      <button type="submit">تسجيل الدخول</button>
    </form>
  );
}

export default LoginForm;

أنماط Custom Hook

أرجع كائنًا بخصائص مسماة لواجهة برمجية أوضح، أو أرجع مصفوفة للتفكيك الموضعي مثل useState. اختر بناءً على كيفية استهلاك المستخدمين لـ hook الخاص بك.

useLocalStorage Hook

Custom hook لمزامنة الحالة مع localStorage:

import { useState, useEffect } from 'react';

function useLocalStorage(key, initialValue) {
  // الحصول على القيمة الأولية من localStorage أو استخدام initialValue
  const [storedValue, setStoredValue] = useState(() => {
    try {
      const item = window.localStorage.getItem(key);
      return item ? JSON.parse(item) : initialValue;
    } catch (error) {
      console.error('خطأ في القراءة من localStorage:', error);
      return initialValue;
    }
  });

  // تحديث localStorage عندما تتغير الحالة
  useEffect(() => {
    try {
      window.localStorage.setItem(key, JSON.stringify(storedValue));
    } catch (error) {
      console.error('خطأ في الكتابة إلى localStorage:', error);
    }
  }, [key, storedValue]);

  return [storedValue, setStoredValue];
}

// الاستخدام
function ThemeToggle() {
  const [theme, setTheme] = useLocalStorage('theme', 'light');

  const toggleTheme = () => {
    setTheme(prevTheme => prevTheme === 'light' ? 'dark' : 'light');
  };

  return (
    <div className={theme}>
      <h2>المظهر الحالي: {theme}</h2>
      <button onClick={toggleTheme}>تبديل المظهر</button>
    </div>
  );
}

export default ThemeToggle;

useFetch Hook

Hook قابل لإعادة الاستخدام لجلب البيانات مع حالات التحميل والخطأ:

import { useState, useEffect } from 'react';

function useFetch(url, options = {}) {
  const [data, setData] = useState(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);

  useEffect(() => {
    let isCancelled = false;

    const fetchData = async () => {
      setLoading(true);
      setError(null);

      try {
        const response = await fetch(url, options);

        if (!response.ok) {
          throw new Error(`خطأ HTTP! الحالة: ${response.status}`);
        }

        const json = await response.json();

        if (!isCancelled) {
          setData(json);
          setLoading(false);
        }
      } catch (err) {
        if (!isCancelled) {
          setError(err.message);
          setLoading(false);
        }
      }
    };

    fetchData();

    // دالة التنظيف لمنع تحديثات الحالة على مكون غير محمل
    return () => {
      isCancelled = true;
    };
  }, [url, JSON.stringify(options)]);

  return { data, loading, error };
}

// الاستخدام
function UserProfile({ userId }) {
  const { data: user, loading, error } = useFetch(
    `https://api.example.com/users/${userId}`
  );

  if (loading) return <div>جاري التحميل...</div>;
  if (error) return <div>خطأ: {error}</div>;
  if (!user) return <div>لم يتم العثور على مستخدم</div>;

  return (
    <div>
      <h2>{user.name}</h2>
      <p>البريد الإلكتروني: {user.email}</p>
    </div>
  );
}

export default UserProfile;

useDebounce Hook

تأخير تحديث قيمة حتى يتوقف المستخدم عن الكتابة:

import { useState, useEffect } from 'react';

function useDebounce(value, delay = 500) {
  const [debouncedValue, setDebouncedValue] = useState(value);

  useEffect(() => {
    // إعداد مؤقت لتحديث القيمة المؤجلة
    const handler = setTimeout(() => {
      setDebouncedValue(value);
    }, delay);

    // مسح المهلة إذا تغيرت القيمة أو تم إلغاء تحميل المكون
    return () => {
      clearTimeout(handler);
    };
  }, [value, delay]);

  return debouncedValue;
}

// الاستخدام في مكون البحث
function SearchComponent() {
  const [searchTerm, setSearchTerm] = useState('');
  const [results, setResults] = useState([]);
  const debouncedSearchTerm = useDebounce(searchTerm, 500);

  useEffect(() => {
    if (debouncedSearchTerm) {
      // تنفيذ البحث بالقيمة المؤجلة
      fetch(`https://api.example.com/search?q=${debouncedSearchTerm}`)
        .then(res => res.json())
        .then(data => setResults(data))
        .catch(err => console.error(err));
    } else {
      setResults([]);
    }
  }, [debouncedSearchTerm]);

  return (
    <div>
      <input
        type="text"
        value={searchTerm}
        onChange={(e) => setSearchTerm(e.target.value)}
        placeholder="بحث..."
      />
      <ul>
        {results.map(item => (
          <li key={item.id}>{item.name}</li>
        ))}
      </ul>
    </div>
  );
}

export default SearchComponent;

تجنب الإفراط في استخدام Custom Hooks

ليس كل المنطق يحتاج إلى استخراجه إلى custom hooks. أنشئها فقط عندما يكون لديك منطق يُعاد استخدامه عبر مكونات متعددة أو عندما يحسن الاستخراج بشكل كبير تنظيم الكود.

useToggle Hook

Hook بسيط لحالة تبديل منطقية:

import { useState, useCallback } from 'react';

function useToggle(initialValue = false) {
  const [value, setValue] = useState(initialValue);

  const toggle = useCallback(() => {
    setValue(v => !v);
  }, []);

  const setTrue = useCallback(() => {
    setValue(true);
  }, []);

  const setFalse = useCallback(() => {
    setValue(false);
  }, []);

  return [value, toggle, setTrue, setFalse];
}

// الاستخدام
function ModalExample() {
  const [isOpen, toggleModal, openModal, closeModal] = useToggle(false);

  return (
    <div>
      <button onClick={openModal}>فتح النافذة</button>

      {isOpen && (
        <div className="modal">
          <h2>محتوى النافذة</h2>
          <button onClick={closeModal}>إغلاق</button>
        </div>
      )}
    </div>
  );
}

useWindowSize Hook

تتبع أبعاد النافذة للسلوك المتجاوب:

import { useState, useEffect } from 'react';

function useWindowSize() {
  const [windowSize, setWindowSize] = useState({
    width: window.innerWidth,
    height: window.innerHeight
  });

  useEffect(() => {
    function handleResize() {
      setWindowSize({
        width: window.innerWidth,
        height: window.innerHeight
      });
    }

    window.addEventListener('resize', handleResize);

    // التنظيف
    return () => {
      window.removeEventListener('resize', handleResize);
    };
  }, []);

  return windowSize;
}

// الاستخدام
function ResponsiveComponent() {
  const { width, height } = useWindowSize();

  return (
    <div>
      <h2>حجم النافذة</h2>
      <p>العرض: {width}px</p>
      <p>الارتفاع: {height}px</p>
      {width < 768 && <p>عرض الهاتف المحمول</p>}
      {width >= 768 && <p>عرض سطح المكتب</p>}
    </div>
  );
}

usePrevious Hook

الحصول على القيمة السابقة للحالة أو الخصائص:

import { useRef, useEffect } from 'react';

function usePrevious(value) {
  const ref = useRef();

  useEffect(() => {
    ref.current = value;
  }, [value]);

  return ref.current;
}

// الاستخدام
function Counter() {
  const [count, setCount] = useState(0);
  const prevCount = usePrevious(count);

  return (
    <div>
      <h2>الحالي: {count}</h2>
      <h3>السابق: {prevCount ?? 'لا يوجد'}</h3>
      <button onClick={() => setCount(count + 1)}>زيادة</button>
    </div>
  );
}

تركيب Hook

يمكن لـ Custom hooks استخدام custom hooks أخرى:

// الجمع بين useLocalStorage و useDebounce
function useLocalStorageWithDebounce(key, initialValue, delay = 500) {
  const [storedValue, setStoredValue] = useLocalStorage(key, initialValue);
  const debouncedValue = useDebounce(storedValue, delay);

  useEffect(() => {
    // مزامنة القيمة المؤجلة إلى localStorage
    setStoredValue(debouncedValue);
  }, [debouncedValue]);

  return [storedValue, setStoredValue];
}

// الاستخدام
function AutoSaveEditor() {
  const [content, setContent] = useLocalStorageWithDebounce(
    'editor-content',
    '',
    1000
  );

  return (
    <div>
      <textarea
        value={content}
        onChange={(e) => setContent(e.target.value)}
        placeholder="اكتب شيئًا... (يُحفظ تلقائيًا في localStorage)"
      />
      <p>المحتوى محفوظ تلقائيًا</p>
    </div>
  );
}

useAsync Hook

Hook عام للتعامل مع العمليات غير المتزامنة:

import { useState, useCallback } from 'react';

function useAsync(asyncFunction) {
  const [status, setStatus] = useState('idle');
  const [value, setValue] = useState(null);
  const [error, setError] = useState(null);

  const execute = useCallback((...params) => {
    setStatus('pending');
    setValue(null);
    setError(null);

    return asyncFunction(...params)
      .then(response => {
        setValue(response);
        setStatus('success');
        return response;
      })
      .catch(error => {
        setError(error);
        setStatus('error');
        throw error;
      });
  }, [asyncFunction]);

  return { execute, status, value, error };
}

// الاستخدام
function UserComponent() {
  const fetchUser = async (userId) => {
    const response = await fetch(`https://api.example.com/users/${userId}`);
    return response.json();
  };

  const { execute, status, value, error } = useAsync(fetchUser);

  const loadUser = () => {
    execute(123);
  };

  return (
    <div>
      <button onClick={loadUser} disabled={status === 'pending'}>
        تحميل المستخدم
      </button>

      {status === 'pending' && <div>جاري التحميل...</div>}
      {status === 'success' && <div>المستخدم: {value.name}</div>}
      {status === 'error' && <div>خطأ: {error.message}</div>}
    </div>
  );
}

useOnClickOutside Hook

الكشف عن النقرات خارج عنصر معين:

import { useEffect, useRef } from 'react';

function useOnClickOutside(handler) {
  const ref = useRef();

  useEffect(() => {
    const listener = (event) => {
      // لا تفعل شيئًا إذا كان النقر على عنصر ref أو عناصره التابعة
      if (!ref.current || ref.current.contains(event.target)) {
        return;
      }
      handler(event);
    };

    document.addEventListener('mousedown', listener);
    document.addEventListener('touchstart', listener);

    return () => {
      document.removeEventListener('mousedown', listener);
      document.removeEventListener('touchstart', listener);
    };
  }, [handler]);

  return ref;
}

// الاستخدام
function Dropdown() {
  const [isOpen, setIsOpen] = useState(false);
  const dropdownRef = useOnClickOutside(() => setIsOpen(false));

  return (
    <div ref={dropdownRef}>
      <button onClick={() => setIsOpen(!isOpen)}>
        تبديل القائمة المنسدلة
      </button>
      {isOpen && (
        <div className="dropdown-menu">
          <div>عنصر 1</div>
          <div>عنصر 2</div>
          <div>عنصر 3</div>
        </div>
      )}
    </div>
  );
}

تمرين 1: useForm Hook

أنشئ hook شامل لإدارة النماذج مع التحقق من الصحة.

// المتطلبات:
// 1. التعامل مع حقول نموذج متعددة
// 2. قواعد تحقق مدمجة (required, email, minLength, إلخ.)
// 3. حالة خطأ لكل حقل
// 4. حالة dirty/touched
// 5. handleSubmit مع التحقق
// 6. وظيفة إعادة التعيين

تمرين 2: useIntersectionObserver Hook

أنشئ hook يكتشف عندما يدخل عنصر إلى منطقة العرض (التحميل الكسول).

// المتطلبات:
// 1. إرجاع ref لإرفاقه بالعنصر
// 2. إرجاع isIntersecting boolean
// 3. قبول الخيارات (threshold, rootMargin)
// 4. تنظيف المراقب عند إلغاء التحميل
// 5. الاستخدام في المكون لتحميل الصور كسولًا

تمرين 3: useMediaQuery Hook

أنشئ hook لمطابقة استعلامات وسائط CSS في JavaScript.

// المتطلبات:
// 1. قبول سلسلة استعلام الوسائط
// 2. إرجاع حالة تطابق boolean
// 3. التحديث عند تغيير حجم النافذة
// 4. أمثلة الاستعلامات: "(min-width: 768px)", "(prefers-color-scheme: dark)"
// 5. تنظيف المستمعين عند إلغاء التحميل

الملخص

  • تستخرج Custom hooks المنطق القابل لإعادة الاستخدام إلى دوال تبدأ بـ "use"
  • اتبع قواعد Hooks: الاستدعاء فقط على المستوى الأعلى ومن دوال React
  • الأنماط الشائعة: useLocalStorage, useFetch, useDebounce, useToggle
  • يمكن لـ Hooks تركيب hooks أخرى لوظائف معقدة
  • أرجع كائنات للخصائص المسماة أو مصفوفات للتفكيك الموضعي
  • أنشئ custom hooks عندما يُعاد استخدام المنطق عبر المكونات
  • تحسن Custom hooks تنظيم الكود وقابلية الاختبار
  • قم دائمًا بتنظيف التأثيرات الجانبية (مستمعي الأحداث، المؤقتات، إلخ.)