React: Custom Hooks

Last updated: 2026-08-26

1. What You'll Learn



2. The Story of a Form Reuse Requirement

Reuse Patterns for Custom Hooks

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) Pain Point: The same logic is repeated across 5 pages

Bob needs to "retrieve user preferences from local storage" on five different pages:

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!)
}
▶ Try it Yourself

Issue: localStorage The read/write logic has been copied and pasted five times across five pages. If we want to add a feature to "listen for storage events," we’d have to modify five files.

(2) Solutions for Custom Hooks

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
}
▶ Try it Yourself

Benefits: 20 lines of logic → 1 line of code. When modifying the logic, you only need to change a single file: useLocalStorage.



3. Rules for Custom Hooks

Rule Description Consequences of Violation
Starting with use Identifying Hooks by React Dependency Names Linter does not check this rule; conditional calls will not trigger errors
Call only at the top level Do not call within loops, conditionals, or nested blocks Hook chain misalignment, state confusion
Call only within function components or custom hooks Do not use within regular functions or class components Runtime error: Invalid Hook call
Returns data/functions, not JSX Hooks return logic, components return UI Violates the separation of concerns, making reuse difficult
Cleaning Up Side Effects useEffect return cleanup function Memory Leaks, Accumulation of Event Listeners

(1) Naming Conventions

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() { }
▶ Try it Yourself

(2) Return Value Design

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
}
▶ Try it Yourself

(3) Any hooks can be used internally

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
▶ Try it Yourself

4. 6 Practical Custom Hooks

Hook Function Hooks Used Internally Return Value
useToggle Boolean Switch Toggle useState, useCallback { value, toggle, setTrue, setFalse }
useDebounce Input anti-shake useState, useEffect debouncedValue
useWindowSize Window size monitoring useState, useEffect { width, height }
useFetch Data Request Tri-state useState, useEffect, useCallback { data, loading, error, refetch }
useIntersectionObserver Visibility detection useState, useEffect isIntersecting
useMediaQuery Responsive breakpoint useState, useEffect matches

(1) Hook 1: useToggle (Toggle Switch)

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>
  )
}
▶ Try it Yourself

(2) Hook 2: useDebounce (anti-jitter)

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..." />
}
▶ Try it Yourself

(3) Hook 3: useWindowSize (window size)

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>
  )
}
▶ Try it Yourself

(4) Hook 4: useFetch (data request)

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>
}
▶ Try it Yourself

(5) Hook 5: useIntersectionObserver (Visibility Detection)

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>
  )
}
▶ Try it Yourself

(6) Hook 6: useMediaQuery (Responsive Breakpoints)

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>
  )
}
▶ Try it Yourself

5. Principles for Reusing Custom Hooks

Principle Explanation Bad Example Good Example
Single Responsibility A hook does only one thing useUserAndTheme() useUser() + useTheme()
Flexible Parameters Provide reasonable default values useFetch('/api/data') Hard-coded URL useFetch(url) Parameterized URL
Complete return values Returns three states: loading, error, and data useFetch() Returns only data useFetch() Returns { data, loading, error }
Resource Cleanup Clean up side effects when the component is unmounted Do not clean up timers/event listeners useEffect return cleanup
Composable Small hooks can be combined into larger hooks useComplexData() 100 lines useFetch() + useDebounce() combination

▶ Example: Combining Multiple Small Hooks

Output:

TEXT 📖 Display only
State: query. Component: 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
▶ Try it Yourself

Output:

TEXT 📖 Display only
Custom hook returns { data, loading, error }. Usage: const { data, loading } = useFetch("/api/users"). Auto-fetches on mount.


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

Expected Output: An image gallery that loads 8 images on initial load; scrolling to the bottom automatically loads more; clicking an image opens a preview (keyboard navigation supported); clicking outside the preview area closes it.


▶ Example 2: useClipboard Hook—Copy to Clipboard

Output:

TEXT 📖 Display only
State: query (setter: setQuery)
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>
  )
}
▶ Try it Yourself

Output:

TEXT 📖 Display only
useClipboard hook: click to copy text → "Copied!" feedback → auto-reset after timeout. Wraps navigator.clipboard API

▶ Example 3: useOnlineStatus Hook—Network Status Detection

Output:

TEXT 📖 Display only
useOnlineStatus: "Online" (green) / "Offline" (red). Listens to navigator.onLine events
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>
  )
}
▶ Try it Yourself

Output:

TEXT 📖 Display only
useOnlineStatus hook: displays "Online" (green) or "Offline" (red). Listens to navigator.onLine and online/offline events

▶ Example 4: useLocalStorage Hook—Persisting State

Output:

TEXT 📖 Display only
State: isOnline (setter: setIsOnline). useEffect manages side effects
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>
  )
}
▶ Try it Yourself

Output:

TEXT 📖 Display only
useLocalStorage("theme", "light") → persists across reloads. Toggle → "dark". Reopening page restores saved value.

▶ Example 5: useCountdown Hook—Countdown

Output:

TEXT 📖 Display only
State: timeleft, isrunning. buttons: start, pause, resume. uses useeffect
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>
  )
}
▶ Try it Yourself

Output:

TEXT 📖 Display only
CountdownTimer component with state: seconds, autostart. Renders interactive UI.

❓ FAQ

Q What is the difference between a custom hook and a regular function?
A You can use React Hooks (such as useState and useEffect) inside a custom hook, but not inside a regular function. Therefore, you should use a custom hook only for "reusable logic that requires Hooks," while a regular function is sufficient for "pure computational logic." For example, formatDate() uses a regular function, while useWindowSize() uses a custom hook.
Q How do you test custom hooks?
A Use the @testing-library/react-hooks library. Testing custom hooks is just like testing a component—render the hook, verify the return value, trigger an update, and verify the changes. For purely logical hooks, testing is very straightforward.
Q When should you extract logic into a custom hook?
A When you see the same logic in two or more components. Don’t extract it too early (before you’re sure the logic will actually be reused), but don’t wait too long either (until you’ve copied and pasted it five times). A useful rule of thumb: the “three-times rule”—if the same logic appears three or more times, you must extract it into a custom hook.
Q Can you use useEffect in a custom Hook?
A Yes, and it’s very common. Custom Hooks can use any built-in Hooks (such as useState, useEffect, useRef, useContext, etc.). For example, the useFetch Hook uses useState internally to manage data, loading, and error states; useEffect to fetch data on mount; and useRef to store the AbortController. This is precisely the value of custom hooks—combining multiple hooks into a single reusable unit of logic.

📖 Summary


📝 Exercises

  1. Basic Problem (Difficulty ⭐): Create a useCounter hook that returns { count, increment, decrement, reset }, with a configurable initial value.
  2. Advanced Problem (Difficulty ⭐⭐): Create a useClipboard hook that wraps the navigator.clipboard API and returns { copied, copy(text) }, with support for copying text to the clipboard.
  3. Challenge (Difficulty: ⭐⭐⭐): Create a useWebSocket hook that wraps a WebSocket connection and returns { data, isConnected, send(message), reconnect() }, supporting automatic reconnection and heartbeat detection.
Web-Tutorial.com

Web-Tutorial Tech Team

A team of developers maintaining programming tutorials. Each tutorial is written and reviewed by developers with expertise in that field. We work to keep our content accurate and reliable — if you spot an issue, please let us know.

100%

🙏 帮我们做得更好

我们是刚上线的编程教程站,几个人的小团队,精力有限。页面虽经检查,难免还有疏漏——链接失效、排版错乱、内容有误、语言生硬……

如果您发现了,麻烦告诉我们,我们会在收到反馈后第一时间进行修复,再次感谢您的光临 🙏