自定义 Hooks 是 React 中复用状态逻辑的利器,但因为其本质是普通函数,容易让开发者在编写时忽略 React 的运行机制,导致一些隐蔽的 bug。以下是在实际开发中最高频的几个坑点及应对方法。
1. 违反 Hooks 调用规则
自定义 Hooks 内部使用了 useState、useEffect 等 Hooks,因此它也必须遵守 Hooks 规则:只在函数组件或自定义 Hook 的顶层调用,不能在条件、循环或嵌套函数中使用。
❌ 错误示例:
function useAsyncData(shouldFetch) {
if (shouldFetch) {
const [data, setData] = useState(null); // 条件分支中调用,破坏链表顺序
}
// ...
}
✅ 正确做法:
function useAsyncData(shouldFetch) {
// 始终在顶层声明状态
const [data, setData] = useState(null);
const [loading, setLoading] = useState(false);
useEffect(() => {
if (!shouldFetch) return;
// 数据请求逻辑...
}, [shouldFetch]);
return { data, loading };
}
原因:React 依赖 Hooks 的调用顺序来维护内部状态链表。如果某次渲染跳过了某个 Hook,顺序错乱会导致状态错位,引发难以追踪的错误。
2. 未清理的副作用(内存泄漏)
自定义 Hook 中经常使用 useEffect 处理定时器、事件监听、订阅等副作用。如果 Hook 被多个组件实例使用,或者组件频繁挂载/卸载,忘记清理副作用会导致内存泄漏。
❌ 错误示例:
function useWindowSize() {
const [size, setSize] = useState(getSize());
useEffect(() => {
window.addEventListener('resize', handleResize);
// 没有返回清理函数,组件卸载后事件监听仍在
}, []);
}
✅ 正确做法:
function useWindowSize() {
const [size, setSize] = useState(getSize());
useEffect(() => {
const handleResize = () => setSize(getSize());
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
}, []);
return size;
}
原则:副作用中创建的任何资源(监听器、定时器、订阅、网络请求)都要在清理函数中销毁或中断。
3. 闭包陷阱与过期引用
自定义 Hook 内部返回的回调函数如果依赖了外部的 state 或 props,但没有声明在依赖数组中,或者使用了 useCallback 但未提供正确依赖,会捕获到旧值,形成“闭包陷阱”。
❌ 错误示例:
function useCounter() {
const [count, setCount] = useState(0);
const logCount = useCallback(() => {
console.log(count); // 始终打印初始值,因为依赖数组是空的
}, []);
return { count, increment: () => setCount(c => c + 1), logCount };
}
✅ 正确做法:
function useCounter() {
const [count, setCount] = useState(0);
const logCount = useCallback(() => {
console.log(count); // 依赖 count,每次变化都会生成新的函数
}, [count]);
// 如果确实需要稳定引用,可以使用 ref 存储最新值
const countRef = useRef(count);
countRef.current = count;
const stableLogCount = useCallback(() => {
console.log(countRef.current); // 始终读到最新值
}, []);
return { count, increment: () => setCount(c => c + 1), logCount: stableLogCount };
}
经验:当自定义 Hook 返回回调函数并且这些函数会在异步回调、事件处理器等场景下被调用时,务必注意闭包过期问题。使用 ref 保存最新值是常见的“逃逸”方案。
4. 依赖项缺失或错误,导致无限循环
自定义 Hook 中的 useEffect 如果没有正确声明依赖项,可能陷入无限更新循环,直到浏览器直接卡死。
❌ 错误示例:
function useFetch(config) {
const [data, setData] = useState(null);
useEffect(() => {
fetch(config.url).then(res => res.json()).then(setData);
}, [config]); // config 是对象,每次渲染都是新的引用,导致效果无限执行
}
✅ 正确做法:
function useFetch(url, options) {
const [data, setData] = useState(null);
useEffect(() => {
let cancelled = false;
fetch(url, options).then(res => res.json()).then(d => {
if (!cancelled) setData(d);
});
return () => { cancelled = true; };
}, [url, options]); // 如果 options 由外部传入且不稳定,需要在文档中明确要求调用方稳定引用
}
更稳健的方案是使用 useRef 固定引用,或在内部对对象进行序列化比较,但这属于深度优化范畴。对于自定义 Hook 来说,更好的做法是在文档中明确说明引用稳定要求,让调用方自行负责,比如要求使用 useMemo 包裹 options。
5. 返回对象未做缓存,导致下游不必要的重渲染
如果自定义 Hook 返回的是一个对象或数组,每次渲染都会生成新的引用,即使内部值未变,也会导致使用了该 Hook 的组件以及使用 React.memo/useMemo/useCallback 的子组件发生不必要的重渲染。
❌ 错误示例:
function useToggle(initial) {
const [value, setValue] = useState(initial);
const toggle = () => setValue(v => !v);
return { value, toggle }; // 每次返回新对象
}
✅ 优化:
function useToggle(initial) {
const [value, setValue] = useState(initial);
const toggle = useCallback(() => setValue(v => !v), []);
// 使用 useMemo 缓存返回对象,只有值变化时才更新引用
return useMemo(() => ({ value, toggle }), [value, toggle]);
}
虽然不一定所有 Hook 都需要这种优化,但在被大量引用或处于敏感路径时,这能避免性能问题。
6. 滥用自定义 Hooks 导致抽象不清晰
自定义 Hooks 的本质是复用状态逻辑,而不是简单的代码抽取。如果只是把几行 useState 包裹成一个函数,可能会增加理解成本而没有带来真正的价值。
❌ 过度抽象:
function useInput() {
const [value, setValue] = useState('');
const onChange = e => setValue(e.target.value);
return { value, onChange };
}
// 这只是一个简单的封装,但没有复用复杂逻辑,直接写 useState 可能更清晰
✅ 有价值的使用场景:
- 复用跨组件的副作用逻辑(如
useRequest、useAuth) - 封装复杂的内部状态同步(如
useScrollSync) - 对第三方库的无侵入 React 绑定(如
useQuery)
原则:自定义 Hook 应该解决一个具体、通用的痛点,而不是无脑取代任何 useState 声明。当逻辑确实在多个组件中重复时再抽象为 Hook。
7. 在 Hook 内部直接修改状态对象/数组
虽然这个问题也存在于常规组件中,但在自定义 Hook 中更隐蔽。有些 Hook 内部会返回修改状态的函数,但实现时直接修改了原对象,导致 React 无法检测变化。
❌ 错误:
function useList(initial) {
const [list, setList] = useState(initial);
const addItem = (item) => {
list.push(item); // 直接修改原数组
setList(list); // 引用不变,React 不会重新渲染
};
return { list, addItem };
}
✅ 正确:
const addItem = useCallback((item) => {
setList(prev => [...prev, item]); // 返回新数组
}, []);
不可变更新是 React 状态管理的基础,自定义 Hook 必须严格遵守。
总结:自定义 Hooks 的常见坑点大多源于对 Hooks 运行机制的疏忽。只要始终谨记:Hooks 规则、闭包与依赖、副作用清理、不可变更新和引用稳定性,就能写出健壮且可复用的逻辑。如果发现自定义 Hook 有很多隐式约束,不妨在 JSDoc 中注明注意事项,或通过 TypeScript 类型强化契约。