React自定义Hook
React 中通过自定义 Hook 复用状态与副作用逻辑的方式、设计边界与高频代码样例。
[!info] related notes
- 所属 MOC: React MOC
- 上位主题: react-hooks
- 相关概念: react-use-state, react-use-effect, react-use-ref, React组件设计原则
- 延伸阅读: react-hooks-interview-deep-dive
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 复用的是逻辑组合,不是视图结构。