React: الخطافات المخصصة

آخر تحديث: 2026-08-26

1. ما ستتعلمه



2. قصة أحد متطلبات إعادة استخدام النماذج

أنماط إعادة الاستخدام للخطافات المخصصة

100%
flowchart TD
    A[Repetitive Logic] --> B[Extract as useXxx]
    B --> C[ComponentsA useXxx]
    B --> D[ComponentsB useXxx]
    B --> E[ComponentsC useXxx]
    
    F[useXxxInside] --> G[useState]
    F --> H[useEffect]
    F --> I[useRef]
    F --> J[useContext]
    
    K[The Rule of Three] --> L{Number of logical repetitions?}
    L -->|1 time| M[No extraction required]
    L -->|2 times| N[Consider extracting]
    L -->|3+ times| O[Must be extracted ✅]
    
    style B fill:#e8f5e9,stroke:#2e7d32
    style O fill:#c8e6c9,stroke:#2e7d32

(1) المشكلة: يتكرر نفس المنطق على مدى 5 صفحات

يحتاج بوب إلى «استرداد تفضيلات المستخدم من التخزين المحلي» في خمس صفحات مختلفة:

JSX
// Page A:Settings Page
function SettingsPage() {
  const [prefs, setPrefs] = useState(() => {
    const saved = localStorage.getItem('preferences')
    return saved ? JSON.parse(saved) : { theme: 'light', lang: 'zh' }
  })

  useEffect(() => {
    localStorage.setItem('preferences', JSON.stringify(prefs))
  }, [prefs])

  // ... Page A specifically UI
}

// Page B:Dashboard
function Dashboard() {
  const [prefs, setPrefs] = useState(() => {
    const saved = localStorage.getItem('preferences')
    return saved ? JSON.parse(saved) : { theme: 'light', lang: 'zh' }
  })

  useEffect(() => {
    localStorage.setItem('preferences', JSON.stringify(prefs))
  }, [prefs])

  // ... Page B specifically UI(Exactly the same logic!)
}
▶ جرّب الكود

المشكلة: localStorage تم نسخ منطق القراءة/الكتابة ولصقه خمس مرات عبر خمس صفحات. إذا أردنا إضافة ميزة «رصد أحداث التخزين»، فسيتعين علينا تعديل خمسة ملفات.

(2) حلول للخطافات المخصصة

JSX
// ---- Custom Hook:Packaging localStorage Reading and Writing ----
function useLocalStorage(key, initialValue) {
  const [value, setValue] = useState(() => {
    try {
      const saved = localStorage.getItem(key)
      return saved ? JSON.parse(saved) : initialValue
    } catch {
      return initialValue
    }
  })

  useEffect(() => {
    try {
      localStorage.setItem(key, JSON.stringify(value))
    } catch (e) {
      console.error('Save to localStorage Failure:', e)
    }
  }, [key, value])

  return [value, setValue]
}

// ---- Use this consistently on all pages ----
function SettingsPage() {
  const [prefs, setPrefs] = useLocalStorage('preferences', {
    theme: 'light', lang: 'zh'
  })
  // ... Specifically UI
}

function Dashboard() {
  const [prefs, setPrefs] = useLocalStorage('preferences', {
    theme: 'light', lang: 'zh'
  })
  // ... Specifically UI
}
▶ جرّب الكود

المزايا: 20 سطراً من المنطق → سطر واحد من التعليمات البرمجية. عند تعديل المنطق، ما عليك سوى تغيير ملف واحد: useLocalStorage.



3. قواعد الخطافات المخصصة

القاعدة الوصف عواقب المخالفة
بدءًا من use تحديد الـ«هوكات» باستخدام أسماء تبعيات React لا يتحقق «Linter» من هذه القاعدة؛ ولن تؤدي الاستدعاءات الشرطية إلى ظهور أخطاء
استدعاء الوظيفة في المستوى الأعلى فقط لا تستدعِ الوظيفة داخل الحلقات أو العبارات الشرطية أو الكتل المتداخلة عدم توافق سلسلة الـ«هوك»، والتشويش في الحالة
يُسمح بالاستدعاء فقط داخل مكونات الدالة أو الخطافات المخصصة لا تستخدم داخل الدوال العادية أو مكونات الفئات خطأ في وقت التشغيل: استدعاء خطاف غير صالح
تُرجع بيانات/دوال، وليس JSX تُرجع «الهوكات» المنطق، بينما تُرجع المكونات واجهة المستخدم تنتهك مبدأ فصل الاهتمامات، مما يجعل إعادة الاستخدام صعبة
معالجة الآثار الجانبية استخدام دالة التنظيف في useEffect تسربات الذاكرة، تراكم مستمعي الأحداث

(1) قواعد تسمية الملفات

JSX
// ✅ It is essential to use Introduction
function useOnlineStatus() { }
function useWindowSize() { }
function useDebounce() { }

// ❌ Not based on use Introduction → React Will not check Hooks Rules
function onlineStatus() { }
function fetchData() { }
▶ جرّب الكود

(2) تصميم قيمة الإرجاع

JSX
// Method 1:Return an array(like useState)
function useToggle(initial = false) {
  const [value, setValue] = useState(initial)
  const toggle = useCallback(() => setValue(v => !v), [])
  return [value, toggle]  // Array,Flexible Deconstruction of Names
}

// Method 2:Return Object(Greater clarity when there are multiple return values)
function useUser() {
  const [user, setUser] = useState(null)
  const [loading, setLoading] = useState(true)
  const [error, setError] = useState(null)
  return { user, loading, error }  // Object,Property Names Are Clear
}

// Method 3:Returns a single value
function useDocumentTitle(title) {
  useEffect(() => { document.title = title }, [title])
  // No return value required
}
▶ جرّب الكود

(3) يمكن استخدام أي «hooks» داخليًّا

JSX
function useData(url) {
  const [data, setData] = useState(null)
  const [loading, setLoading] = useState(true)
  const [error, setError] = useState(null)

  useEffect(() => {
    let cancelled = false
    setLoading(true)
    
    fetch(url)
      .then(res => {
        if (!res.ok) throw new Error('Request Failed')
        return res.json()
      })
      .then(data => { if (!cancelled) { setData(data); setLoading(false) }})
      .catch(err => { if (!cancelled) { setError(err.message); setLoading(false) }})

    return () => { cancelled = true }
  }, [url])

  return { data, loading, error }
}
// Used internally useState + useEffect + useCallback(If necessary)
// Custom Hook You can combine any of the built-in features Hooks
▶ جرّب الكود

4. 6 خطافات مخصصة عملية

الخطاف الوظيفة الخطافات المستخدمة داخليًّا القيمة المرجعة
useToggle مفتاح تبديل منطقي useState، useCallback { value, toggle, setTrue, setFalse }
useDebounce مدخلات مقاومة الاهتزاز useState، useEffect debouncedValue
useWindowSize مراقبة حجم النافذة useState، useEffect { width, height }
useFetch طلب بيانات ثلاثي الحالات useState، useEffect، useCallback { data, loading, error, refetch }
useIntersectionObserver كشف الرؤية useState، useEffect isIntersecting
useMediaQuery نقطة توقف التجاوب useState، useEffect matches

(1) العنصر 1: useToggle (مفتاح التبديل)

JSX
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 }
}

// Usage
function ModalExample() {
  const { value: isOpen, setTrue: open, setFalse: close } = useToggle(false)
  return (
    <div>
      <button onClick={open}>Open the pop-up window</button>
      {isOpen && (
        <div style={{ position: 'fixed', top: 0, left: 0, right: 0, bottom: 0, backgroundColor: 'rgba(0,0,0,0.5)', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
          <div style={{ backgroundColor: 'white', padding: '24px', borderRadius: '8px' }}>
            <h2>Pop-up Title</h2>
            <p>Pop-up Content</p>
            <button onClick={close}>Close</button>
          </div>
        </div>
      )}
    </div>
  )
}
▶ جرّب الكود

(2) هوك 2: useDebounce (مضاد الاهتزاز)

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

  useEffect(() => {
    const timer = setTimeout(() => {
      setDebouncedValue(value)
    }, delay)

    return () => clearTimeout(timer)
  }, [value, delay])

  return debouncedValue
}

// Usage:Search Input Stabilization
function SearchBox() {
  const [query, setQuery] = useState('')
  const debouncedQuery = useDebounce(query, 500)

  // Only at debouncedQuery Request only when there is a change(The user has stopped typing 500ms after )
  useEffect(() => {
    if (debouncedQuery) {
      fetch('/api/search?q=' + debouncedQuery).then(/* ... */)
    }
  }, [debouncedQuery])

  return <input value={query} onChange={e => setQuery(e.target.value)} placeholder="Search..." />
}
▶ جرّب الكود

(3) Hook 3: useWindowSize (حجم النافذة)

JSX
function useWindowSize() {
  const [size, setSize] = useState({
    width: window.innerWidth,
    height: window.innerHeight
  })

  useEffect(() => {
    let timeoutId

    function handleResize() {
      // Image Stabilization:50ms several times inside resize Execute only once
      clearTimeout(timeoutId)
      timeoutId = setTimeout(() => {
        setSize({ width: window.innerWidth, height: window.innerHeight })
      }, 50)
    }

    window.addEventListener('resize', handleResize)
    return () => {
      window.removeEventListener('resize', handleResize)
      clearTimeout(timeoutId)
    }
  }, [])

  return size
}

// Usage
function ResponsiveComponent() {
  const { width } = useWindowSize()
  return (
    <p>
      Current window width:{width}px
      {width < 768 ? '(Mobile View)' : width < 1024 ? '(Flat View)' : '(Desktop View)'}
    </p>
  )
}
▶ جرّب الكود

(4) الخطاف 4: useFetch (طلب البيانات)

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

  useEffect(() => {
    let cancelled = false
    setLoading(true)
    setError(null)

    fetch(url, options)
      .then(res => {
        if (!res.ok) throw new Error(`HTTP ${res.status}: ${res.statusText}`)
        return res.json()
      })
      .then(data => { if (!cancelled) { setData(data); setLoading(false) }})
      .catch(err => { if (!cancelled) { setError(err.message); setLoading(false) }})

    return () => { cancelled = true }
  }, [url])

  // Resubmit Request
  const refetch = useCallback(() => {
    setLoading(true)
    fetch(url, options)
      .then(res => res.json())
      .then(data => { setData(data); setLoading(false) })
      .catch(err => { setError(err.message); setLoading(false) })
  }, [url])

  return { data, loading, error, refetch }
}

// Usage
function UserProfile({ userId }) {
  const { data: user, loading, error, refetch } = useFetch(`/api/users/${userId}`)

  if (loading) return <p>Loading......</p>
  if (error) return <p>Error:{error} <button onClick={refetch}>Retry</button></p>
  return <h1>{user.name}</h1>
}
▶ جرّب الكود

(5) النقطة الخامسة: استخدام IntersectionObserver (الكشف عن الرؤية)

JSX
function useIntersectionObserver(ref, options = {}) {
  const [isIntersecting, setIsIntersecting] = useState(false)

  useEffect(() => {
    if (!ref.current) return

    const observer = new IntersectionObserver(([entry]) => {
      setIsIntersecting(entry.isIntersecting)
    }, { threshold: options.threshold || 0.1, ...options })

    observer.observe(ref.current)

    return () => observer.disconnect()
  }, [ref, options.threshold])

  return isIntersecting
}

// Usage:Lazy Loading of Images
function LazyImage({ src, alt }) {
  const imgRef = useRef(null)
  const isVisible = useIntersectionObserver(imgRef)
  const [loaded, setLoaded] = useState(false)

  return (
    <div ref={imgRef} style={{ minHeight: '200px', backgroundColor: '#f5f5f5' }}>
      {isVisible && (
        <img
          src={src}
          alt={alt}
          onLoad={() => setLoaded(true)}
          style={{ width: '100%', opacity: loaded ? 1 : 0, transition: 'opacity 0.3s' }}
        />
      )}
    </div>
  )
}
▶ جرّب الكود

(6) النقطة السادسة: useMediaQuery (نقاط التوقف التفاعلية)

JSX
function useMediaQuery(query) {
  const [matches, setMatches] = useState(() => window.matchMedia(query).matches)

  useEffect(() => {
    const mql = window.matchMedia(query)
    function handler(e) { setMatches(e.matches) }
    mql.addEventListener('change', handler)
    return () => mql.removeEventListener('change', handler)
  }, [query])

  return matches
}

// Usage
function ResponsiveLayout() {
  const isMobile = useMediaQuery('(max-width: 768px)')
  const isTablet = useMediaQuery('(min-width: 769px) and (max-width: 1024px)')
  const isDesktop = useMediaQuery('(min-width: 1025px)')

  return (
    <div>
      {isMobile && <MobileMenu />}
      {isDesktop && <FullSidebar />}
      <p>
        {isMobile ? '📱 Cell Phone' : isTablet ? '📟 Tablet' : '💻 Computer'}
      </p>
    </div>
  )
}
▶ جرّب الكود

5. مبادئ إعادة استخدام الخطافات المخصصة

المبدأ الشرح مثال سيئ مثال جيد
المسؤولية الواحدة لا يقوم الـ«هوك» إلا بشيء واحد useUserAndTheme() useUser() + useTheme()
المعلمات المرنة توفير قيم افتراضية معقولة useFetch('/api/data') عنوان URL مبرمج بشكل ثابت useFetch(url) عنوان URL معلماتي
قيم الإرجاع الكاملة تُرجع ثلاث حالات: التحميل، والخطأ، والبيانات useFetch() تُرجع البيانات فقط useFetch() تُرجع { data, loading, error }
تنظيف الموارد تنظيف الآثار الجانبية عند إلغاء تثبيت المكون عدم تنظيف المؤقتات/مستمعي الأحداث تنظيف ما يرجعه useEffect
قابل للتركيب يمكن دمج الخطافات الصغيرة لتشكيل خطافات أكبر useComplexData() 100 سطر تركيبة useFetch() + useDebounce()

▶ مثال: دمج عدة خطافات صغيرة

JSX
// ============================================
// Example:Combine multiple small Hook Building Complex Search Features
// ============================================

// 1. Small hook:Input Stabilization
function useDebounce(value, delay) { /* ... */ }

// 2. Small hook:Data Request
function useFetch(url) { /* ... */ }

// 3. Small hook:Local Storage
function useLocalStorage(key, initial) { /* ... */ }

// 4. Combined into a large Hook:Search
function useSearch(defaultQuery = '') {
  const [query, setQuery] = useState(defaultQuery)
  const [history, setHistory] = useLocalStorage('search_history', [])
  const debouncedQuery = useDebounce(query, 500)
  const { data, loading, error } = useFetch(
    debouncedQuery ? `/api/search?q=${debouncedQuery}` : null
  )

  function search(newQuery) {
    setQuery(newQuery)
    // Save Search History
    if (newQuery.trim()) {
      setHistory(prev => [newQuery, ...prev.filter(h => h !== newQuery)].slice(0, 10))
    }
  }

  return { query, setQuery: search, results: data, loading, error, history }
}
// useSearch Combined internally useState + useDebounce + useFetch + useLocalStorage
▶ جرّب الكود

6. مثال كامل: معرض الصور (الجمع بين عدة هوكات مخصصة)

JSX
// ============================================
// Complete Example:Photo Gallery(Combination 3 Custom Hook)
// ============================================

import { useState, useEffect, useCallback, useRef } from 'react'

// ---- Custom Hook 1:Infinite Scrolling ----
function useInfiniteScroll(callback) {
  const sentinelRef = useRef(null)

  useEffect(() => {
    const sentinel = sentinelRef.current
    if (!sentinel) return

    const observer = new IntersectionObserver(([entry]) => {
      if (entry.isIntersecting) callback()
    }, { threshold: 0.1 })

    observer.observe(sentinel)
    return () => observer.disconnect()
  }, [callback])

  return sentinelRef
}

// ---- Custom Hook 2:Keyboard Navigation ----
function useKeyPress(targetKey, callback) {
  useEffect(() => {
    function handler(e) {
      if (e.key === targetKey) callback(e)
    }
    window.addEventListener('keydown', handler)
    return () => window.removeEventListener('keydown', handler)
  }, [targetKey, callback])
}

// ---- Custom Hook 3:Click here ----
function useClickOutside(callback) {
  const ref = useRef(null)

  useEffect(() => {
    function handler(e) {
      if (ref.current && !ref.current.contains(e.target)) {
        callback()
      }
    }
    document.addEventListener('mousedown', handler)
    return () => document.removeEventListener('mousedown', handler)
  }, [callback])

  return ref
}

// ---- Image Gallery Component ----
function ImageGallery() {
  const [images, setImages] = useState([])
  const [page, setPage] = useState(1)
  const [selected, setSelected] = useState(null)
  const [loading, setLoading] = useState(false)

  // Loading image
  const loadMore = useCallback(() => {
    if (loading) return
    setLoading(true)
    const newImages = Array.from({ length: 8 }, (_, i) => ({
      id: images.length + i + 1,
      url: `https://picsum.photos/seed/${images.length + i + 1}/300/200`,
      title: `Image ${images.length + i + 1}`
    }))
    setTimeout(() => {
      setImages(prev => [...prev, ...newImages])
      setPage(p => p + 1)
      setLoading(false)
    }, 800)
  }, [loading, images.length])

  // First Load
  useEffect(() => { loadMore() }, [])

  // Infinite Scroll: Load More
  const sentinelRef = useInfiniteScroll(loadMore)

  // Click here to close the preview
  const previewRef = useClickOutside(() => setSelected(null))

  // Escape Key to close the preview
  useKeyPress('Escape', () => setSelected(null))
  // Use the left and right arrow keys to switch between images
  useKeyPress('ArrowLeft', () => {
    if (selected) {
      const idx = images.findIndex(i => i.id === selected.id)
      if (idx > 0) setSelected(images[idx - 1])
    }
  })
  useKeyPress('ArrowRight', () => {
    if (selected) {
      const idx = images.findIndex(i => i.id === selected.id)
      if (idx < images.length - 1) setSelected(images[idx + 1])
    }
  })

  return (
    <div style={{ maxWidth: '900px', margin: '0 auto' }}>
      <h2>🖼️ Photo Gallery</h2>
      <p style={{ color: '#666', fontSize: '13px' }}>Loaded {images.length} images | Keyboard:← Previous → Next Escape Close</p>

      {/* Image Grid */}
      <div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: '12px' }}>
        {images.map(img => (
          <div key={img.id} onClick={() => setSelected(img)}
            style={{ cursor: 'pointer', borderRadius: '8px', overflow: 'hidden', boxShadow: '0 2px 8px rgba(0,0,0,0.1)' }}>
            <img src={img.url} alt={img.title}
              style={{ width: '100%', height: '150px', objectFit: 'cover', transition: 'transform 0.3s' }}
              onMouseEnter={e => e.currentTarget.style.transform = 'scale(1.05)'}
              onMouseLeave={e => e.currentTarget.style.transform = 'scale(1)'} />
            <p style={{ padding: '8px', margin: 0, fontSize: '13px', textAlign: 'center' }}>{img.title}</p>
          </div>
        ))}
      </div>

      {/* Load More Triggers */}
      <div ref={sentinelRef} style={{ textAlign: 'center', padding: '20px' }}>
        {loading ? <p>⏳ Loading......</p> : <p style={{ color: '#999' }}>↓ Scroll to load more</p>}
      </div>

      {/* Image Preview(Click here to close) */}
      {selected && (
        <div style={{
          position: 'fixed', top: 0, left: 0, right: 0, bottom: 0,
          backgroundColor: 'rgba(0,0,0,0.8)',
          display: 'flex', alignItems: 'center', justifyContent: 'center',
          zIndex: 1000
        }}>
          <div ref={previewRef} style={{ maxWidth: '80vw', maxHeight: '80vh' }}>
            <img src={selected.url.replace('300/200', '600/400')} alt={selected.title}
              style={{ maxWidth: '100%', maxHeight: '80vh', borderRadius: '8px' }} />
            <p style={{ color: 'white', textAlign: 'center', marginTop: '12px' }}>
              {selected.title}(Click here to close)
            </p>
          </div>
        </div>
      )}
    </div>
  )
}

النتيجة المتوقعة: معرض صور يعرض 8 صور عند التحميل الأولي؛ ويؤدي التمرير إلى أسفل الصفحة إلى تحميل المزيد تلقائيًا؛ ويؤدي النقر على صورة إلى فتح نافذة معاينة (مع دعم التنقل باستخدام لوحة المفاتيح)؛ ويؤدي النقر خارج منطقة المعاينة إلى إغلاقها.


▶ المثال 2: هوك useClipboard — النسخ إلى الحافظة

JSX
function useClipboard() {
  const [copied, setCopied] = useState(false)

  async function copy(text) {
    try {
      await navigator.clipboard.writeText(text)
      setCopied(true)
      setTimeout(() => setCopied(false), 2000)
    } catch {
      setCopied(false)
    }
  }

  return { copied, copy }
}

function CopyButton({ text }) {
  const { copied, copy } = useClipboard()
  return (
    <button onClick={() => copy(text)}
      style={{ padding: '4px 12px', cursor: 'pointer', background: copied ? '#52c41a' : '#f0f0f0', color: copied ? 'white' : '#333', border: 'none', borderRadius: 4 }}>
      {copied ? 'Copied!' : 'Copy'}
    </button>
  )
}
▶ جرّب الكود

▶ المثال 3: ربط useOnlineStatus — الكشف عن حالة الشبكة

JSX
function useOnlineStatus() {
  const [isOnline, setIsOnline] = useState(navigator.onLine)

  useEffect(() => {
    function handleOnline() { setIsOnline(true) }
    function handleOffline() { setIsOnline(false) }
    window.addEventListener('online', handleOnline)
    window.addEventListener('offline', handleOffline)
    return () => {
      window.removeEventListener('online', handleOnline)
      window.removeEventListener('offline', handleOffline)
    }
  }, [])

  return isOnline
}

function StatusBar() {
  const isOnline = useOnlineStatus()
  return (
    <div style={{ padding: '8px 16px', background: isOnline ? '#f6ffed' : '#fff2f0', borderBottom: `1px solid ${isOnline ? '#b7eb8f' : '#ffccc7'}` }}>
      {isOnline ? '🟢 Online' : '🔴 Offline — changes will sync when reconnected'}
    </div>
  )
}
▶ جرّب الكود

▶ المثال 4: ربط useLocalStorage — حفظ الحالة

JSX
function useLocalStorage(key, initialValue) {
  const [value, setValue] = useState(() => {
    try {
      const saved = localStorage.getItem(key)
      return saved !== null ? JSON.parse(saved) : initialValue
    } catch { return initialValue }
  })

  useEffect(() => {
    try { localStorage.setItem(key, JSON.stringify(value)) }
    catch (e) { console.error('localStorage save failed:', e) }
  }, [key, value])

  return [value, setValue]
}

function ThemeToggle() {
  const [theme, setTheme] = useLocalStorage('theme', 'light')
  return (
    <button onClick={() => setTheme(t => t === 'light' ? 'dark' : 'light')}
      style={{
        padding: '8px 16px', cursor: 'pointer', borderRadius: 4, border: 'none',
        background: theme === 'dark' ? '#333' : '#f0f0f0',
        color: theme === 'dark' ? 'white' : '#333',
      }}>
      {theme === 'dark' ? '🌙 Dark' : '☀️ Light'} (saved)
    </button>
  )
}
▶ جرّب الكود

▶ المثال 5: هوك useCountdown — العد التنازلي

JSX
function useCountdown(seconds, autostart = false) {
  const [timeLeft, setTimeLeft] = useState(seconds)
  const [isRunning, setIsRunning] = useState(autostart)

  useEffect(() => {
    if (!isRunning || timeLeft <= 0) return
    const timer = setInterval(() => {
      setTimeLeft(prev => {
        if (prev <= 1) { setIsRunning(false); return 0 }
        return prev - 1
      })
    }, 1000)
    return () => clearInterval(timer)
  }, [isRunning, timeLeft])

  function start() { setTimeLeft(seconds); setIsRunning(true) }
  function pause() { setIsRunning(false) }
  function resume() { setIsRunning(true) }

  const minutes = Math.floor(timeLeft / 60)
  const secs = timeLeft % 60
  const formatted = `${String(minutes).padStart(2, '0')}:${String(secs).padStart(2, '0')}`

  return { timeLeft, formatted, isRunning, start, pause, resume }
}

function CountdownTimer() {
  const { formatted, isRunning, start, pause, resume } = useCountdown(300)
  return (
    <div style={{ textAlign: 'center', padding: 20 }}>
      <p style={{ fontSize: 48, fontFamily: 'monospace', margin: '0 0 16px' }}>{formatted}</p>
      <div style={{ display: 'flex', gap: 8, justifyContent: 'center' }}>
        {!isRunning ? <button onClick={start} style={{ padding: '8px 16px', cursor: 'pointer' }}>Start</button>
          : <button onClick={pause} style={{ padding: '8px 16px', cursor: 'pointer' }}>Pause</button>}
        {isRunning || <button onClick={resume} style={{ padding: '8px 16px', cursor: 'pointer' }}>Resume</button>}
      </div>
    </div>
  )
}
▶ جرّب الكود

❓ أسئلة شائعة

س ما الفرق بين هوك مخصص ودالة عادية؟
ج يمكنك استخدام هوكات React (مثل useState وuseEffect) داخل هوك مخصص، ولكن لا يمكنك استخدامها داخل دالة عادية. لذلك، يجب استخدام هوك مخصص فقط لـ«المنطق القابل لإعادة الاستخدام الذي يتطلب استخدام هوكس»، في حين أن الدالة العادية كافية لـ«المنطق الحسابي البحت». على سبيل المثال، يستخدم formatDate() دالة عادية، بينما يستخدم useWindowSize() هوك مخصص.
س كيف يمكن اختبار الخطافات المخصصة؟
ج استخدم مكتبة @testing-library/react-hooks. اختبار الخطافات المخصصة يشبه تمامًا اختبار المكونات — قم بعرض الخطاف، وتحقق من القيمة المرجعة، وقم بتشغيل عملية التحديث، وتحقق من التغييرات. بالنسبة للخطافات المنطقية البحتة، يكون الاختبار بسيطًا للغاية.
س متى ينبغي عليك استخراج المنطق إلى هوك مخصص؟
ج عندما ترى نفس المنطق في مكونين أو أكثر. لا تقم باستخراجه مبكرًا جدًّا (قبل أن تتأكد من أن المنطق سيُعاد استخدامه فعليًّا)، ولكن لا تنتظر طويلاً أيضًا (حتى تقوم بنسخه ولصقه خمس مرات). وهناك قاعدة عامة مفيدة تُعرف بـ«قاعدة الثلاث مرات» — إذا ظهر نفس المنطق ثلاث مرات أو أكثر، فيجب عليك استخراجه إلى هوك مخصص.
س هل يمكن استخدام useEffect في هوك مخصص؟
ج نعم، وهذا أمر شائع جدًّا. يمكن للهوكات المخصصة استخدام أي هوكات مدمجة (مثل useState، useEffect، useRef، useContext، إلخ). على سبيل المثال، يستخدم هوك useFetch هوك useState داخليًّا لإدارة البيانات والتحميل وحالات الخطأ؛ ويستخدم useEffect لجلب البيانات عند التثبيت؛ ويستخدم useRef لتخزين AbortController. وهذه هي بالضبط قيمة الهوكات المخصصة — دمج هوكات متعددة في وحدة منطقية واحدة قابلة لإعادة الاستخدام.

📖 ملخص


📝 تمارين

  1. المشكلة الأساسية (الصعوبة ⭐): أنشئ ربطًا useCounter يُرجع { count, increment, decrement, reset }، مع قيمة أولية قابلة للتعيين.
  2. مشكلة متقدمة (درجة الصعوبة ⭐⭐): قم بإنشاء هوك useClipboard يلتف حول واجهة برمجة التطبيقات navigator.clipboard ويعيد { copied, copy(text) }، مع دعم نسخ النص إلى الحافظة.
  3. التحدي (الصعوبة: ⭐⭐⭐): قم بإنشاء دالة useWebSocket تغلف اتصال WebSocket وتُرجع { data, isConnected, send(message), reconnect() }، مع دعم إعادة الاتصال التلقائي واكتشاف نبض الاتصال.
Web-Tutorial.com

فريق Web-Tutorial التقني

منصة دروس برمجية يديرها عدة مطورين. كل درس يتم كتابته ومراجعته بواسطة مطورين متخصصين في المجال. نعمل على ضمان دقة وموثوقية المحتوى — إذا لاحظت أي مشكلة، فيرجى إخبارنا.

100%