起因
新人 React 开发普遍熟"useState / useEffect / useMemo / useCallback" 名字
但用法常踩坑。下面是我帮 review code 时反复指出的 6 个点。
1. Hooks 只能在 React 函数 top-level 调
// ❌
function MyComp({ user }) {
if (user) {
const [name, setName] = useState(user.name) // 错!hook 在 if 里
}
return ...
}
// ✅
function MyComp({ user }) {
const [name, setName] = useState(user?.name ?? '')
// hook 在 top level,逻辑在 hook 之后
...
}
为什么:React 用 hook 调用顺序 来匹配 state。条件调用 → 顺序变 →
state 错位。
eslint-plugin-react-hooks 的 rules-of-hooks 规则自动检查。
必须开。
2. useEffect 不是 "componentDidMount"
新人常用法:
useEffect(() => {
fetchData().then(setData)
}, []) // 类似 componentDidMount
问题:
- React Strict Mode 在 dev 双调用 → effect 跑两次
- 切 prop 不重 fetch
- cleanup 经常漏写
更深的问题:useEffect 是同步外部系统,不是 lifecycle hook。
// useEffect 真正的用途
useEffect(() => {
// 同步外部:subscribe DOM event / WebSocket / 第三方库
const sub = thirdPartyLib.subscribe(handler)
return () => sub.unsubscribe() // cleanup 必须
}, [handler])
数据获取应该用 React Query / SWR / RSC,不是 useEffect。
事件订阅 / 第三方库初始化才用 useEffect。
3. dependency array 不能撒谎
function MyComp({ userId }) {
useEffect(() => {
fetchUser(userId).then(setUser)
}, []) // ❌ 用到 userId 但没写依赖 → 切 userId 不 refetch
}
eslint exhaustive-deps 规则强制:
useEffect(() => {
fetchUser(userId).then(setUser)
}, [userId]) // ✅
不要为了"骗" linter 写 // eslint-disable-next-line。
真要省 refetch 改其它策略(debounce / 单独 ref)。
4. 不要在 useEffect 里 setState 然后依赖那个 state
function MyComp() {
const [count, setCount] = useState(0)
useEffect(() => {
setCount(c => c + 1) // ❌ 触发 effect 重跑 → setState → 无限循环
}, [count])
}
修正取决于意图:
// 只初始化一次
useEffect(() => {
setCount(initialCount)
}, []) // 但更好的是用 useState 初值
// 派生 state 用 useMemo 或直接计算
const doubled = count * 2 // 不需要 state
90% "我有个 state 依赖另一个 state" 都该用 useMemo / 直接计算。
5. useMemo / useCallback:默认不要用
function MyComp({ items }) {
const total = useMemo(() => items.reduce((s, i) => s + i.price, 0), [items])
return <span>{total}</span>
}
reduce 100 个 item 极快(毫秒级)。
useMemo 本身的 hook 开销 + 依赖比较 > 计算成本。
实际更慢。
useMemo 真的有用的时候:
- 计算确实贵(profile 显示 > 16ms)
- 结果作为 React.memo 子组件的 prop
- 结果作为另一个 useEffect 的依赖(保持稳定)
// ✅ 派生值传给 memoized 子组件
const filtered = useMemo(() => items.filter(complex), [items, filter])
return <MemoedList items={filtered} />
// ✅ 给 useEffect 稳定依赖
const config = useMemo(() => ({ url, timeout }), [url, timeout])
useEffect(() => { subscribe(config) }, [config])
不传 React.memo / 不进 useEffect deps,useMemo 多余。
React 19 + React Compiler 会自动决定哪些 memo,写代码时不用关心。
6. setState 是异步的,但批量也异步
const [count, setCount] = useState(0)
function handleClick() {
setCount(count + 1)
setCount(count + 1)
setCount(count + 1)
// count 只 +1,不是 +3
console.log(count) // 仍是 0(这次 render 的 count)
}
原因:
- React 同一 event 内 batch setState(多次合并成一次 render)
count是闭包捕获的旧值
修正:函数式 setState
function handleClick() {
setCount(c => c + 1)
setCount(c => c + 1)
setCount(c => c + 1)
// count +3
}
c => c + 1 每次拿到最新 state。
读最新值(罕见用例):
function handleClick() {
setCount(c => c + 1)
// 这一行后 count 仍是旧值(要等下次 render)
// 要立刻拿新值用 ref
}
要立刻执行更新后效果用 flushSync(React 18+):
import { flushSync } from 'react-dom'
flushSync(() => {
setCount(c => c + 1)
})
// 这里 DOM 已经更新到新 count
性能差,少用。
7. ref vs state
// state:用作 UI 输出
const [count, setCount] = useState(0)
// ref:用作"persistence 但不触发 render"
const timerRef = useRef<NodeJS.Timer | null>(null)
const renderCountRef = useRef(0)
useEffect(() => {
timerRef.current = setInterval(...)
return () => clearInterval(timerRef.current)
}, [])
何时用 ref:
- DOM ref(input focus)
- 长生命周期 mutable(timer / WebSocket / observer)
- 计数器 / debounce token(不需要 re-render)
何时用 state:
- 视图反映的数据
经验:只要 UI 不看这个值就用 ref。
8. custom hook:纯函数 + 命名 use 开头
function useDebounce<T>(value: T, delay: number): T {
const [debounced, setDebounced] = useState(value)
useEffect(() => {
const t = setTimeout(() => setDebounced(value), delay)
return () => clearTimeout(t)
}, [value, delay])
return debounced
}
// 用
function Search() {
const [input, setInput] = useState('')
const debouncedInput = useDebounce(input, 500)
useEffect(() => { search(debouncedInput) }, [debouncedInput])
}
custom hook 内部调其它 hook → 受 hook rules 约束。
命名必须 use 开头 linter 才识别 + 启用规则检查。
9. 控制 vs 非控制 input
// 控制(推荐):state 是真相
const [val, setVal] = useState('')
<input value={val} onChange={e => setVal(e.target.value)} />
// 非控制:DOM 是真相
const ref = useRef<HTMLInputElement>(null)
<input defaultValue="hello" ref={ref} />
const value = ref.current?.value
控制:每次 input 触发 re-render(小开销,大表单累积)。
非控制:性能好但 React 不知道 value,复杂表单要混用。
react-hook-form 用非控制 + ref 拿值,避开 re-render,是高性能表单
首选。
10. 经典反模式:把 hook 当 class state
// ❌ 一堆 useState
const [name, setName] = useState('')
const [email, setEmail] = useState('')
const [phone, setPhone] = useState('')
const [age, setAge] = useState(0)
或者:
// ❌ 一个 object state(更新很麻烦)
const [form, setForm] = useState({ name: '', email: '', phone: '', age: 0 })
setForm({ ...form, name: 'Alice' }) // 容易漏字段
用 useReducer 或外部 state(Zustand / Jotai / react-hook-form):
const [form, dispatch] = useReducer(formReducer, initialForm)
复杂状态用专门工具。
调试 hooks
React DevTools → Components → 选组件 → Hooks 一栏。
能看到每个 hook 当前值。注意 useReducer / useMemo 显示有限。
总结
| 反模式 | 该做的 |
|---|---|
| useEffect 做 fetch | React Query / SWR / RSC |
| useEffect 依赖 state 触发更新 | 直接派生 / useMemo |
| 无脑 useMemo / useCallback | 默认不用,profile 后按需 |
| dependency array 写 [] 撒谎 | 实事求是 + lint |
| 闭包 setState 用旧值 | setX(prev => ...) |
| 一堆 useState 平铺 | useReducer / 第三方 store |
| 在 if / loop 调 hook | 顶层调用,逻辑在内部分支 |
熟悉这些后写 React 体感顺很多 + bug 少很多。
踩过的坑
-
strict mode 双调 effect:dev 模式 effect 跑两次。cleanup 必须
做对,否则 subscribe / setTimeout 等"两个跑半个" 状态。 -
useEffect 跑两次拉两次 API:dev 干扰开发体验。
<StrictMode>内层包不要去 / 或者用 React Query 等自动 dedup。 -
dep 数组的 object / function 每次新引用:
tsx useEffect(..., [{ x: 1 }]) // 每次都"新对象"
触发 effect 每次都跑。用 stable ref 或 useMemo。 -
custom hook 名没 use 开头:lint 不识别 → 规则不生效 → 隐藏 bug。
-
React 19 / Compiler 期望写法:未来 useMemo / useCallback 大多
不需要。养成"先简洁后优化" 的习惯。
登录后参与评论。