React 自定義 Hook (Custom Hooks)

自定義 Hook 是一種重用狀態邏輯的方式。當你發現多個元件有相似的邏輯時,可以把這些邏輯抽取成自定義 Hook。

什麼是自定義 Hook?

自定義 Hook 就是一個以 use 開頭的函式,內部可以使用其他 Hook:

function useMyHook() {
  const [state, setState] = useState(initialValue)
  // 可以使用任何 Hook
  useEffect(() => {
    // ...
  }, [])

  return state
}

命名規則:

  • 必須以 use 開頭(例如 useCounteruseFetch
  • 這讓 React 知道這是一個 Hook,需要遵守 Hook 的規則

基本範例:useCounter

import { useState } from 'react'

// 自定義 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 }
}

// 使用自定義 Hook
function Counter() {
  const { count, increment, decrement, reset } = useCounter(0)

  return (
    <div>
      <p>Count: {count}</p>
      <button onClick={increment}>+1</button>
      <button onClick={decrement}>-1</button>
      <button onClick={reset}>Reset</button>
    </div>
  )
}

// 另一個元件也可以使用同一個 Hook
function AnotherCounter() {
  const counter1 = useCounter(10)
  const counter2 = useCounter(100)

  return (
    <div>
      <p>Counter 1: {counter1.count}</p>
      <p>Counter 2: {counter2.count}</p>
    </div>
  )
}
每次使用自定義 Hook,都會得到獨立的狀態。多個元件使用同一個 Hook,它們的狀態不會互相影響。

實用範例

useToggle - 開關狀態

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 Modal() {
  const { value: isOpen, toggle, setFalse: close } = useToggle()

  return (
    <div>
      <button onClick={toggle}>開啟 Modal</button>
      {isOpen && (
        <div className="modal">
          <p>Modal 內容</p>
          <button onClick={close}>關閉</button>
        </div>
      )}
    </div>
  )
}

useLocalStorage - 同步 localStorage

import { useState, useEffect } from 'react'

function useLocalStorage(key, initialValue) {
  // 初始化時從 localStorage 讀取
  const [value, setValue] = useState(() => {
    try {
      const item = localStorage.getItem(key)
      return item ? JSON.parse(item) : initialValue
    } catch {
      return initialValue
    }
  })

  // 當值改變時同步到 localStorage
  useEffect(() => {
    try {
      localStorage.setItem(key, JSON.stringify(value))
    } catch {
      console.error('無法儲存到 localStorage')
    }
  }, [key, value])

  return [value, setValue]
}

// 使用
function Settings() {
  const [theme, setTheme] = useLocalStorage('theme', 'light')
  const [fontSize, setFontSize] = useLocalStorage('fontSize', 16)

  return (
    <div>
      <select value={theme} onChange={(e) => setTheme(e.target.value)}>
        <option value="light">淺色</option>
        <option value="dark">深色</option>
      </select>
      <input type="number" value={fontSize} onChange={(e) => setFontSize(Number(e.target.value))} />
    </div>
  )
}

useFetch - 資料獲取

import { useState, useEffect } from 'react'

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

  useEffect(() => {
    let cancelled = false

    async function fetchData() {
      setLoading(true)
      setError(null)

      try {
        const response = await fetch(url)
        if (!response.ok) {
          throw new Error(`HTTP error! status: ${response.status}`)
        }
        const json = await response.json()

        if (!cancelled) {
          setData(json)
        }
      } catch (e) {
        if (!cancelled) {
          setError(e.message)
        }
      } finally {
        if (!cancelled) {
          setLoading(false)
        }
      }
    }

    fetchData()

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

  return { data, loading, error }
}

// 使用
function UserProfile({ userId }) {
  const { data: user, loading, error } = useFetch(`https://api.example.com/users/${userId}`)

  if (loading) return <p>載入中...</p>
  if (error) return <p>錯誤: {error}</p>
  if (!user) return null

  return (
    <div>
      <h1>{user.name}</h1>
      <p>{user.email}</p>
    </div>
  )
}

useDebounce - 防抖

import { useState, useEffect } from 'react'

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

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

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

  return debouncedValue
}

// 使用:搜尋時減少 API 呼叫
function SearchInput() {
  const [query, setQuery] = useState('')
  const debouncedQuery = useDebounce(query, 300)

  // 只有當 debouncedQuery 改變時才會觸發搜尋
  useEffect(() => {
    if (debouncedQuery) {
      console.log('搜尋:', debouncedQuery)
      // 執行搜尋 API 呼叫
    }
  }, [debouncedQuery])

  return <input value={query} onChange={(e) => setQuery(e.target.value)} placeholder="搜尋..." />
}

useWindowSize - 監聽視窗大小

import { useState, useEffect } from 'react'

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

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

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

  return size
}

// 使用
function ResponsiveComponent() {
  const { width, height } = useWindowSize()

  return (
    <div>
      <p>
        視窗大小: {width} x {height}
      </p>
      {width < 768 ? <MobileLayout /> : <DesktopLayout />}
    </div>
  )
}

useOnClickOutside - 點擊外部

import { useEffect, useRef } from 'react'

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

  useEffect(() => {
    function handleClick(event) {
      if (ref.current && !ref.current.contains(event.target)) {
        handler()
      }
    }

    document.addEventListener('mousedown', handleClick)
    return () => document.removeEventListener('mousedown', handleClick)
  }, [handler])

  return ref
}

// 使用:點擊外部關閉下拉選單
function Dropdown() {
  const [isOpen, setIsOpen] = useState(false)
  const ref = useOnClickOutside(() => setIsOpen(false))

  return (
    <div ref={ref}>
      <button onClick={() => setIsOpen(!isOpen)}>選單</button>
      {isOpen && (
        <ul className="dropdown-menu">
          <li>選項 1</li>
          <li>選項 2</li>
          <li>選項 3</li>
        </ul>
      )}
    </div>
  )
}

自定義 Hook 的最佳實踐

1. 單一職責

每個 Hook 只做一件事:

// ✅ 好:專注於一件事
function useDocumentTitle(title) {
  useEffect(() => {
    document.title = title
  }, [title])
}

// ❌ 不好:做太多事
function useEverything(title, theme, user) {
  // 設定標題
  // 設定主題
  // 獲取使用者資料
  // ...
}

2. 返回有意義的值

// ✅ 好:返回有結構的資料
function useFetch(url) {
  return { data, loading, error, refetch }
}

// ✅ 也可以返回陣列(像 useState)
function useToggle(initial) {
  return [value, toggle]
}

3. 處理清理邏輯

function useInterval(callback, delay) {
  useEffect(() => {
    const id = setInterval(callback, delay)
    // 記得清理
    return () => clearInterval(id)
  }, [callback, delay])
}

組合多個 Hook

自定義 Hook 可以使用其他自定義 Hook:

function useUser(userId) {
  const { data: user, loading, error } = useFetch(`https://api.example.com/users/${userId}`)

  const [isFollowing, setIsFollowing] = useLocalStorage(`following-${userId}`, false)

  return {
    user,
    loading,
    error,
    isFollowing,
    toggleFollow: () => setIsFollowing((f) => !f),
  }
}