用了两年 React,Hooks 的坑踩了个遍。整理一下最常遇到的几个,都是代码里真实出过的问题。
useState 的更新不是同步的
jsx
function Counter() {
const [count, setCount] = useState(0)
const handleClick = () => {
setCount(count + 1)
setCount(count + 1)
setCount(count + 1)
// count 最终是 1,不是 3
}
}
三次 setCount(count + 1) 用的都是同一个 count 值(0),所以结果都是 1。React 会把同一轮渲染中的多次 setState 合并(batching)。
正确写法:用函数式更新
jsx
const handleClick = () => {
setCount(prev => prev + 1)
setCount(prev => prev + 1)
setCount(prev => prev + 1)
// 这次是 3
}
useEffect 的依赖数组别骗自己
jsx
function UserProfile({ userId }) {
const [user, setUser] = useState(null)
useEffect(() => {
fetchUser(userId).then(setUser)
}, []) // 依赖数组漏了 userId!
return <div>{user?.name}</div>
}
依赖数组写了 [],意思是"只在挂载时执行一次"。但 userId 变了的时候,effect 不会重新跑,页面显示的还是第一个用户的信息。
ESLint 的 exhaustive-deps 规则一定要开,它会帮你检查依赖数组是否完整。
jsx
useEffect(() => {
fetchUser(userId).then(setUser)
}, [userId]) // 正确:userId 变了重新拉取
如果确实只想跑一次(比如埋点上报),那就老老实实写 [],但要在注释里说明为什么。
useRef 不触发重渲染
这个不算坑,但很多人刚用的时候会困惑:
jsx
function Timer() {
const [count, setCount] = useState(0)
const renderCount = useRef(0)
renderCount.current += 1
console.log('render:', renderCount.current) // 能正确打印 1, 2, 3...
return <button onClick={() => setCount(c => c + 1)}>{count}</button>
}
useRef 的 .current 变了,但页面不会重新渲染。它就是一个不会触发更新的"变量容器"。适合存不需要反映到 UI 上的值(定时器 ID、DOM 引用、上一次的 props 等)。
useMemo 不是免费的
很多人觉得 useMemo 是性能优化万能药,什么都包一层:
jsx
// 过度使用
const name = useMemo(() => user.firstName + ' ' + user.lastName, [user])
const isAdmin = useMemo(() => user.role === 'admin', [user])
const items = useMemo(() => list.filter(x => x.active), [list])
useMemo 本身有开销------要存依赖数组、做浅比较。如果计算本身很轻量(字符串拼接、简单判断),useMemo 的开销可能比直接算还大。
什么时候该用:
- 计算确实很重(大数组排序/过滤、复杂 JSON 转换)
- 作为其他 Hook 的依赖(
useEffect的依赖项需要引用稳定) - 传给子组件的对象/数组(避免子组件不必要的重渲染)
什么时候不该用:
- 简单运算、字符串拼接
- 组件本身就很轻量
- 没有传给子组件,也没有作为依赖
useCallback 和 React.memo 要配套
单独用 useCallback 没什么意义:
jsx
// 没有意义
function Parent() {
const handleClick = useCallback(() => {
console.log('clicked')
}, [])
return <Child onClick={handleClick} />
}
// Child 没有 memo,每次 Parent 渲染 Child 都会重渲染
function Child({ onClick }) {
console.log('Child render')
return <button onClick={onClick}>Click</button>
}
useCallback 缓存了函数引用,但 Child 没有用 React.memo 包裹,所以不管 onClick 变没变,Child 都会重渲染。
jsx
// 配套使用
const Child = React.memo(function Child({ onClick }) {
console.log('Child render')
return <button onClick={onClick}>Click</button>
})
React.memo 做浅比较,useCallback 保证引用不变 → 浅比较通过 → 子组件跳过渲染。
自定义 Hook 复用逻辑
写了三遍类似的"请求 + loading + error"之后,就该抽成自定义 Hook 了:
jsx
function useFetch(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 => 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 }
}
// 使用
function UserList() {
const { data, loading, error } = useFetch('/api/users')
if (loading) return <Spinner />
if (error) return <div>Error: {error}</div>
return <ul>{data.map(u => <li key={u.id}>{u.name}</li>)}</ul>
}
cancelled 标记是为了防止组件卸载后还 setState(会报警告)。
总结
Hooks 的核心心智模型是"每次渲染都是一次独立的函数调用"。理解了这一点,大部分坑都能自己推理出来:useState 的批量更新、useEffect 的闭包问题、useCallback 的引用缓存,都是这个模型的直接推论。