React Hooks 几个少有人讲清楚的规则 + 反模式

起因

新人 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)
}

原因:

  1. React 同一 event 内 batch setState(多次合并成一次 render)
  2. 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 少很多。

踩过的坑

  1. strict mode 双调 effect:dev 模式 effect 跑两次。cleanup 必须
    做对,否则 subscribe / setTimeout 等"两个跑半个" 状态。

  2. useEffect 跑两次拉两次 API:dev 干扰开发体验。
    <StrictMode> 内层包不要去 / 或者用 React Query 等自动 dedup。

  3. dep 数组的 object / function 每次新引用
    tsx useEffect(..., [{ x: 1 }]) // 每次都"新对象"
    触发 effect 每次都跑。用 stable ref 或 useMemo。

  4. custom hook 名没 use 开头:lint 不识别 → 规则不生效 → 隐藏 bug。

  5. React 19 / Compiler 期望写法:未来 useMemo / useCallback 大多
    不需要。养成"先简洁后优化" 的习惯。

精确评价 共 0 人评价
可复现性
可复现 · 0 不可复现 · 0
文风
文风流畅 · 0 文风晦涩 · 0
立场
支持 · 0 反对 · 0

登录后即可对本帖作出评价。

评论区 0 条 · 所有人可在此交流

登录后参与评论。

还没有评论,来说两句。