自定义 Hook 的设计与拆分——从复用逻辑到复用心智模型

很多人第一次自定义 Hook,出发点都差不多:
组件里有三四个
useState多个
useEffect还有一点事件处理逻辑
代码越来越长,看着有些累
于是顺手抽出一个 useXxx.ts,把代码搬过去。 文件是变短了,但新的问题很快又来了:
这个 Hook 的输入很多,返回值也很多。
逻辑虽然搬走了,但读起来还是乱。
子组件调用时,不知道哪些返回值是“状态”,哪些是“动作”,哪些只是内部实现。
这说明问题不在“有没有抽 Hook”,而在于:
自定义 Hook 不是“把代码换个文件放”,而是把一段状态逻辑重新设计成一个稳定的接口。
如果组件是 UI 的组合单位,那自定义 Hook 就是状态逻辑的组合单位。
一、先别急着抽:什么时候值得做成自定义 Hook?
React 官方对 custom hook 的定义很朴素:
它是一个以
use开头的函数;它内部可以调用其他 Hook;
它的目的,是在多个组件之间复用有状态逻辑。
但在日常项目里,“复用”只是其中一种理由。 真正常见的抽取时机,通常有三类:
1. 同一段状态逻辑在多个组件里重复出现
比如:
useWindowSizeuseOnlineStatususeDebouncedValueuseFetchUser
这种最直观,就是典型的逻辑复用。
2. 一个组件里的 Hooks 组合已经形成了“一个完整能力”
比如:
搜索输入 + 防抖 + 请求 + 取消旧请求
弹窗开关 + ESC 关闭 + 点击外部关闭
表单值 + 校验 + 提交 + 重置
这时候即使暂时只有一个组件用,也值得抽。 因为你不是为了“复用代码行数”,而是在复用一块完整的交互语义。
3. 你想把组件从“业务控制器”还原成“UI 描述”
这类是我觉得最值得抽的情况。
很多组件一开始是这样的:
上面一排
useState中间三段
useEffect下面五个 handler
最后才是 JSX
读起来像一个微型控制器,而不是一个视图。 这时候抽 Hook,不是为了炫技,而是为了让组件重新变回“展示层”。
二、自定义 Hook 抽的不是代码,而是“问题边界”
这是整篇最重要的一句。
很多人抽 Hook 时,容易按“技术点”拆:
一个 Hook 管 state
一个 Hook 管 effect
一个 Hook 管 callback
结果拆出来一堆 useA、useB、useC,谁都看不懂。
更好的方式是按“问题边界”拆,也就是按业务上要解决的那件事拆。
比如你做一个搜索面板,不要拆成:
useKeywordStateuseDebounceuseSearchEffect
而更应该优先思考:
这段逻辑整体上是不是“搜索能力”?
这个能力对外最重要的输入和输出是什么?
于是你就得到一个更语义化的接口:
const {
query,
setQuery,
results,
loading,
error,
} = useSearchUsers();这样一来,调用方不需要知道内部到底用了 useEffect、useRef 还是 useCallback。
它只关心:这个 Hook 提供了一个“用户搜索能力”。
所以设计自定义 Hook 时,优先回答这两个问题:
这个 Hook 在解决什么完整问题?
调用它的组件,最少需要知道什么?
三、一个坏例子:只是把组件代码搬走,不叫设计
先看一段常见的“抽了但没完全抽”的代码。
组件里原来的逻辑
function SearchPanel() {
const [query, setQuery] = useState('');
const [users, setUsers] = useState<User[]>([]);
const [loading, setLoading] = useState(false);
useEffect(() => {
if (!query) {
setUsers([]);
return;
}
let ignore = false;
setLoading(true);
fetch(`/api/users?q=${query}`)
.then(res => res.json())
.then(data => {
if (!ignore) setUsers(data);
})
.finally(() => {
if (!ignore) setLoading(false);
});
return () => {
ignore = true;
};
}, [query]);
return ...
}很多人会直接抽成:
function useSearchPanelLogic() {
const [query, setQuery] = useState('');
const [users, setUsers] = useState<User[]>([]);
const [loading, setLoading] = useState(false);
useEffect(() => {
...
}, [query]);
return { query, setQuery, users, loading };
}这不算错,但也谈不上“设计”。 因为它只是把原组件逻辑整块搬到另一个文件,名字换成了 Hook。
问题在于:
useSearchPanelLogic这个名字太贴近页面,不是能力名。如果以后别的地方也要“搜索用户”,这个 Hook 可能并不通用。
它把“页面”和“能力”绑在一起了。
更好的做法,是抽成一个语义更清楚的 Hook:
function useUserSearch(initialQuery = '') {
const [query, setQuery] = useState(initialQuery);
const [users, setUsers] = useState<User[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<Error | null>(null);
useEffect(() => {
if (!query) {
setUsers([]);
return;
}
let ignore = false;
setLoading(true);
setError(null);
fetch(`/api/users?q=${query}`)
.then(res => res.json())
.then(data => {
if (!ignore) setUsers(data);
})
.catch(err => {
if (!ignore) setError(err);
})
.finally(() => {
if (!ignore) setLoading(false);
});
return () => {
ignore = true;
};
}, [query]);
return {
query,
setQuery,
users,
loading,
error,
};
}这时候它已经不是“页面逻辑搬家”,而是一个真正有边界的状态能力模块了。
四、设计自定义 Hook 时,输入输出最好保持“少而稳”
自定义 Hook 最容易失控的地方,是返回值越来越多。
比如:
const {
query,
setQuery,
users,
setUsers,
loading,
setLoading,
error,
setError,
refetch,
reset,
inputRef,
} = useUserSearch();这类 API 一看就有点不对劲。 原因不是“值太多”本身,而是它把内部状态管理权也暴露出去了。
一个 Hook 设计得越成熟,对外暴露的东西通常越像两类:
状态
动作
比如:
const {
query,
users,
loading,
error,
setQuery,
refetch,
reset,
} = useUserSearch();这里就很清楚:
query/users/loading/error是状态。setQuery/refetch/reset是动作。
而像 setUsers、setLoading、setError 这种,一般都属于 Hook 内部实现,不应该随便暴露给调用者。
否则这个 Hook 就失去了自己的“约束力”,外部可以随意把它状态改乱。
所以有个很实用的设计原则: > Hook 的返回值,优先暴露“可读状态”和“语义动作”,少暴露内部 setter。
这和组件封装是一个道理。
五、把前几篇讲过的规则,真正沉淀进 Hook 里
自定义 Hook 最有价值的地方,不是“复用代码”,而是把前面几篇的最佳实践,沉淀成团队可复用的默认写法。
也就是说,一个好的自定义 Hook,应该天然具备这些特征:
不滥用 effect。
不同步派生状态。
处理好闭包和依赖数组。
只在必要时使用 ref 和 callback。
比如我们前面讲过“防抖搜索”,如果每个组件都自己手写一遍:
有的人会忘记清理 timer;
有的人会写出 stale closure;
有的人会把中间状态同步成一团。
这时候把它封成 Hook,其实是在统一一套正确做法:
function useDebouncedValue<T>(value: T, delay: number) {
const [debouncedValue, setDebouncedValue] = useState(value);
useEffect(() => {
const id = setTimeout(() => {
setDebouncedValue(value);
}, delay);
return () => clearTimeout(id);
}, [value, delay]);
return debouncedValue;
}然后业务组件就可以写成:
const [query, setQuery] = useState('');
const debouncedQuery = useDebouncedValue(query, 400);这样,调用者根本不需要重新思考防抖 effect 的边界问题。 这就是自定义 Hook 的真正价值:把判断成本前置一次,后面的人只管用。
六、3 个最值得在项目里沉淀的基础 Hook
这一节不追求花哨,讲三个最容易落地、也最能体现设计感的 Hook。
1. usePrevious
function usePrevious<T>(value: T) {
const ref = useRef<T | undefined>(undefined);
useEffect(() => {
ref.current = value;
}, [value]);
return ref.current;
}这个 Hook 很适合做对比逻辑,比如:
判断值是否刚刚变化
做简单动画触发
对比前后 props
它的意义不是代码复用本身,而是把“前一个值”的语义提炼出来。
2. useDebouncedValue
function useDebouncedValue<T>(value: T, delay: number) {
const [debouncedValue, setDebouncedValue] = useState(value);
useEffect(() => {
const id = setTimeout(() => {
setDebouncedValue(value);
}, delay);
return () => clearTimeout(id);
}, [value, delay]);
return debouncedValue;
}这个 Hook 非常适合:
搜索框
表单联动
频繁输入后的延迟请求
它把“防抖的时间语义”从组件里抽掉了。
3. useEventCallback / latest-callback 模式
function useEventCallback<T extends (...args: any[]) => any>(fn: T) {
const fnRef = useRef(fn);
useEffect(() => {
fnRef.current = fn;
}, [fn]);
return useCallback((...args: Parameters<T>) => {
return fnRef.current(...args);
}, []);
}这个 Hook 的场景是:
你想拿到一个稳定函数引用;
它内部始终执行最新逻辑。
这类模式特别适合第三方事件系统、定时器工具、订阅桥接。
不过这里也要提醒一句:
它是工具模式,不是业务组件默认写法。
普通场景老实写依赖,往往更简单。
七、什么时候不该抽自定义 Hook?
这一节很重要。
不是组件里出现了两个 useState,就必须抽 Hook。
下面几种情况,通常不值得抽:
1. 逻辑非常短,而且只在当前组件里出现一次
比如:
const [open, setOpen] = useState(false);这种抽成 useModalOpen() 反而画蛇添足。
因为它没有形成一个真正的能力边界,只是把简单逻辑包了一层名字。
2. 抽出去之后,接口比原组件还难理解
如果一个 Hook 需要:
8 个参数
12 个返回值
一堆 options 配置
那你大概率不是在抽象,而是在把复杂度隐藏起来。 复杂度没有消失,只是换了个地方。
3. 逻辑和页面结构强绑定,暂时看不出稳定边界
有些东西现在就只属于这个页面,而且你还没摸清它最终会怎么演化。 这时候宁可先留在组件里,也别急着抽一个半成品 Hook。
抽 Hook 的时机,通常是“边界已经浮现”,不是“代码刚一变长就赶紧拆”。
八、一个完整重构示例:从页面控制器到自定义 Hook
先看一个比较常见的页面代码:
function UserSearchPage() {
const [query, setQuery] = useState('');
const [users, setUsers] = useState<User[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<Error | null>(null);
const debouncedQuery = useDebouncedValue(query, 400);
useEffect(() => {
if (!debouncedQuery) {
setUsers([]);
return;
}
let ignore = false;
setLoading(true);
setError(null);
fetch(`/api/users?q=${debouncedQuery}`)
.then(res => res.json())
.then(data => {
if (!ignore) setUsers(data);
})
.catch(err => {
if (!ignore) setError(err);
})
.finally(() => {
if (!ignore) setLoading(false);
});
return () => {
ignore = true;
};
}, [debouncedQuery]);
return (
<div>
<input
value={query}
onChange={e => setQuery(e.target.value)}
/>
{loading && <div>Loading...</div>}
{error && <div>{error.message}</div>}
<ul>
{users.map(user => (
<li key={user.id}>{user.name}</li>
))}
</ul>
</div>
);
}这段还算清楚,但随着需求变多,很快就会变厚。 这时候可以抽成:
function useUserSearch(delay = 400) {
const [query, setQuery] = useState('');
const [users, setUsers] = useState<User[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<Error | null>(null);
const debouncedQuery = useDebouncedValue(query, delay);
useEffect(() => {
if (!debouncedQuery) {
setUsers([]);
return;
}
let ignore = false;
setLoading(true);
setError(null);
fetch(`/api/users?q=${debouncedQuery}`)
.then(res => res.json())
.then(data => {
if (!ignore) setUsers(data);
})
.catch(err => {
if (!ignore) setError(err);
})
.finally(() => {
if (!ignore) setLoading(false);
});
return () => {
ignore = true;
};
}, [debouncedQuery]);
const reset = useCallback(() => {
setQuery('');
setUsers([]);
setError(null);
setLoading(false);
}, []);
return {
query,
setQuery,
users,
loading,
error,
reset,
};
}然后页面变成:
function UserSearchPage() {
const {
query,
setQuery,
users,
loading,
error,
reset,
} = useUserSearch();
return (
<div>
<input
value={query}
onChange={e => setQuery(e.target.value)}
/>
<button onClick={reset}>清空</button>
{loading && <div>Loading...</div>}
{error && <div>{error.message}</div>}
<ul>
{users.map(user => (
<li key={user.id}>{user.name}</li>
))}
</ul>
</div>
);
}这样改完最大的好处不是“代码少了”,而是页面终于重新像页面了:
它负责展示。
useUserSearch负责状态能力。
这才是一个组件 + Hook 的自然分工。
九、这一篇可以带走的结论
自定义 Hook 的目标不是单纯复用代码,而是复用一段状态逻辑的边界和语义。
该抽 Hook 的时机,通常是逻辑已经形成一个完整能力,或者你想把组件重新还原成展示层。
抽 Hook 时要按“问题边界”拆,而不是按
state/effect/callback这种技术点拆。Hook 对外优先暴露“状态 + 动作”,少暴露内部 setter。
一个好的自定义 Hook,应该把前面几篇讲过的正确心智模型沉淀进去,而不是把复杂度原样搬家。