React自定义Hook

React 中通过自定义 Hook 复用状态与副作用逻辑的方式、设计边界与高频代码样例。

#tech / dev / frame #resource / react #type / concept #status / growing

[!info] related notes

React自定义Hook

自定义 Hook 是 React 里复用逻辑的标准方式。它复用的不是 UI,而是状态、依赖和副作用的组合方式。

一句话定义

当多处组件共享同一种“状态 + 副作用 + 处理逻辑”时,就可以考虑把它提炼成自定义 Hook。

它到底复用什么

  • 状态组织
  • 副作用流程
  • 事件处理逻辑
  • 对外暴露的行为接口

它不复用什么

  • JSX 结构本身
  • 样式结构本身

这些通常还是组件的职责。

一个最重要的事实

自定义 Hook 复用的是逻辑,不是共享同一份状态实例。

function useCounter() {
  const [count, setCount] = useState(0)
  return [count, setCount] as const
}

function CounterA() {
  const [count, setCount] = useCounter()
  return <button onClick={() => setCount(count + 1)}>{count}</button>
}

function CounterB() {
  const [count, setCount] = useCounter()
  return <button onClick={() => setCount(count + 1)}>{count}</button>
}

虽然两个组件都调用了同一个 useCounter,但它们拿到的是各自独立的一套状态。

什么时候值得抽出来

  • 多个组件里出现重复请求逻辑
  • 多个地方都有类似的开关、订阅、分页、搜索流程
  • 组件内部逻辑太重,已经影响可读性

命名和设计直觉

  • 函数名必须以 use 开头
  • 优先暴露稳定、易懂的接口,而不是把内部实现细节全抛出去
  • Hook 内部可以继续调用其他 Hook
  • 如果只是提取一个纯函数,不要硬做成 Hook

高频样例

下面这几类是最值得先掌握的自定义 Hook 模式。

1. useToggle:封装布尔开关

适合弹窗开关、折叠面板、明暗主题切换这类简单状态。

import { useState } from 'react'

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

  const open = () => setValue(true)
  const close = () => setValue(false)
  const toggle = () => setValue(prev => !prev)

  return { value, open, close, toggle }
}

组件里这样用:

function DialogDemo() {
  const { value: open, open: showDialog, close, toggle } = useToggle(false)

  return (
    <>
      <button onClick={showDialog}>打开弹窗</button>
      <button onClick={toggle}>切换状态</button>
      {open && (
        <div>
          <p>这是一个弹窗</p>
          <button onClick={close}>关闭</button>
        </div>
      )}
    </>
  )
}

这个 Hook 的价值不在代码变短,而在于把“布尔状态怎么操作”统一成稳定接口。

2. useDebounce:给输入值做防抖

适合搜索框、筛选输入、联想建议,避免每敲一个字就立刻请求。

import { useEffect, useState } from 'react'

function useDebounce<T>(value: T, delay = 300) {
  const [debouncedValue, setDebouncedValue] = useState(value)

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

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

  return debouncedValue
}

组件里这样用:

function SearchBox() {
  const [keyword, setKeyword] = useState('')
  const debouncedKeyword = useDebounce(keyword, 500)

  useEffect(() => {
    if (!debouncedKeyword) return
    console.log('发起搜索:', debouncedKeyword)
  }, [debouncedKeyword])

  return (
    <input
      value={keyword}
      onChange={e => setKeyword(e.target.value)}
      placeholder="输入关键词"
    />
  )
}

这类 Hook 的关键是把定时器创建和清理封装掉,让组件只关心“我要拿到稳定输入值”。

3. usePrevious:拿到上一次的值

适合对比前后 props、记录上一次滚动位置、调试某个值的变化轨迹。

import { useEffect, useRef } from 'react'

function usePrevious<T>(value: T) {
  const ref = useRef<T | undefined>(undefined)

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

  return ref.current
}

组件里这样用:

function PriceDiff({ price }: { price: number }) {
  const previousPrice = usePrevious(price)

  return (
    <div>
      <div>当前价格: {price}</div>
      <div>上一次价格: {previousPrice ?? '首次渲染'}</div>
    </div>
  )
}

这里用 useRef 而不是 useState,因为“上一次值”的变化本身不需要触发额外重渲染。

4. useLocalStorage:把状态持久化到本地

适合主题设置、表单草稿、用户偏好这类需要刷新后保留的值。

import { useEffect, useState } from 'react'

function useLocalStorage<T>(key: string, initialValue: T) {
  const [value, setValue] = useState<T>(() => {
    const raw = window.localStorage.getItem(key)
    return raw ? JSON.parse(raw) as T : initialValue
  })

  useEffect(() => {
    window.localStorage.setItem(key, JSON.stringify(value))
  }, [key, value])

  return [value, setValue] as const
}

组件里这样用:

function ThemeSwitcher() {
  const [theme, setTheme] = useLocalStorage<'light' | 'dark'>('theme', 'light')

  return (
    <button onClick={() => setTheme(theme === 'light' ? 'dark' : 'light')}>
      当前主题: {theme}
    </button>
  )
}

这个 Hook 把“初始化读取 + 状态保存”合并在一起,组件只保留业务表达。

5. useFetch:封装请求状态机

适合统一管理 loading / data / error 三件套。

import { useEffect, useState } from 'react'

type FetchState<T> = {
  data: T | null
  loading: boolean
  error: string | null
}

function useFetch<T>(url: string) {
  const [state, setState] = useState<FetchState<T>>({
    data: null,
    loading: true,
    error: null,
  })

  useEffect(() => {
    let cancelled = false

    async function run() {
      setState({
        data: null,
        loading: true,
        error: null,
      })

      try {
        const response = await fetch(url)
        if (!response.ok) {
          throw new Error('request failed')
        }

        const json = await response.json() as T

        if (!cancelled) {
          setState({
            data: json,
            loading: false,
            error: null,
          })
        }
      } catch (error) {
        if (!cancelled) {
          setState({
            data: null,
            loading: false,
            error: error instanceof Error ? error.message : 'unknown error',
          })
        }
      }
    }

    run()

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

  return state
}

组件里这样用:

type User = {
  id: number
  name: string
}

function UserList() {
  const { data, loading, error } = useFetch<User[]>('/api/users')

  if (loading) return <p>加载中...</p>
  if (error) return <p>请求失败: {error}</p>

  return (
    <ul>
      {data?.map(user => (
        <li key={user.id}>{user.name}</li>
      ))}
    </ul>
  )
}

这种写法的重点不是“所有请求都必须抽 Hook”,而是当很多组件都在重复写相同请求状态机时,抽出来会明显更清晰。

常见误区

  • 只是为了“高级感”就把很短的逻辑硬抽成 Hook
  • 抽出来后接口反而更难理解
  • 让 Hook 知道太多具体 UI 细节
  • 在 Hook 里偷偷少写依赖,靠运气避免重复执行
  • 把本来应该直接计算的派生值也塞进 Hook

什么时候不要抽

  • 逻辑只在一个组件里出现,而且很短
  • 抽完后参数太多,调用处反而更难读
  • Hook 已经和某个具体页面的 UI 细节强绑定

最短判断题

如果你想复用的是“状态 + 副作用 + 处理流程”,考虑自定义 Hook。

如果你想复用的是“按钮长什么样、布局怎么排”,那通常还是组件抽象问题。

最短记忆方式

自定义 Hook 复用的是逻辑组合,不是视图结构。

创建于 2026/3/19 更新于 2026/5/27