React
用自定义 Hook 复用 React 里的状态逻辑
这篇笔记记录一下我在工作中逐渐开始使用 React 自定义 Hook 的过程。 写 React 页面时,经常会碰到一些重复逻辑。 有些逻辑和 UI 交互有关,比如: 鼠标是否移动到某个元素上。 点击元素外部时关闭弹窗。 窗口宽度变化时更新布局。 页面滚动到某个位置后显示返回顶部按钮。 还有一些逻辑和数据处理有关,比如:

这篇笔记记录一下我在工作中逐渐开始使用 React 自定义 Hook 的过程。
写 React 页面时,经常会碰到一些重复逻辑。
有些逻辑和 UI 交互有关,比如:
- 鼠标是否移动到某个元素上。
- 点击元素外部时关闭弹窗。
- 窗口宽度变化时更新布局。
- 页面滚动到某个位置后显示返回顶部按钮。
还有一些逻辑和数据处理有关,比如:
- 请求接口并维护加载状态。
- 搜索输入停止一段时间后再发请求。
- 把状态保存到
localStorage。 - 监听网络是否断开。
这些逻辑都可以直接写在业务组件里。
但是写多了以后,组件里会出现很多 useState、useEffect、事件监听和清理函数。页面真正想表达的业务内容,反而被这些细节盖住了。
这时可以把通用逻辑封装成自定义 Hook。
自定义 Hook 是什么
自定义 Hook 本质上还是一个普通函数。
它的名字以 use 开头,内部可以调用 React 提供的 Hook:
import { useState } from "react";
export function useBoolean(initialValue = false) {
const [value, setValue] = useState(initialValue);
return {
value,
open: () => setValue(true),
close: () => setValue(false),
toggle: () => setValue((currentValue) => !currentValue),
};
}使用时:
function Demo() {
const modal = useBoolean();
return (
<>
<button onClick={modal.open}>打开弹窗</button>
{modal.value && (
<div>
弹窗内容
<button onClick={modal.close}>关闭</button>
</div>
)}
</>
);
}自定义 Hook 复用的是状态逻辑,不是同一份状态。
如果两个组件分别调用:
const modal = useBoolean();它们会得到两份互相独立的状态。
为什么要封装 Hook
看一个没有封装的例子:
function UserSearch() {
const [keyword, setKeyword] = useState("");
const [debouncedKeyword, setDebouncedKeyword] = useState("");
useEffect(() => {
const timer = setTimeout(() => {
setDebouncedKeyword(keyword);
}, 300);
return () => {
clearTimeout(timer);
};
}, [keyword]);
return (
<input
value={keyword}
onChange={(event) => setKeyword(event.target.value)}
placeholder={`正在搜索:${debouncedKeyword}`}
/>
);
}代码没有错。
但是业务组件里混进了防抖计时器的实现细节。
封装成:
const debouncedKeyword = useDebouncedValue(keyword, 300);以后,业务组件只需要表达:
我需要一个延迟更新的 keyword而不用每次重新写定时器和清理逻辑。
我觉得自定义 Hook 的价值有点像前面提到的 Flex 组件:
Flex把样式细节整理成更清楚的布局语义。- 自定义 Hook 把状态和副作用细节整理成更清楚的行为语义。
第一类:处理 UI 交互的 Hook
鼠标移动到元素上:useHover
CSS 本身有 :hover:
.button:hover {
background: blue;
}如果只是改颜色,CSS 已经够用了。
但是有些场景需要把“鼠标是否悬停”传进 React 状态:
- 鼠标移动到卡片上时,显示额外按钮。
- 悬停时切换图表数据。
- 悬停时加载预览内容。
- 父组件需要知道子元素是否处于悬停状态。
这时可以写一个 useHover:
import { useState } from "react";
export function useHover() {
const [isHovered, setIsHovered] = useState(false);
return {
isHovered,
hoverProps: {
onMouseEnter: () => setIsHovered(true),
onMouseLeave: () => setIsHovered(false),
},
};
}使用时:
function UserCard() {
const { isHovered, hoverProps } = useHover();
return (
<div {...hoverProps}>
<span>用户信息</span>
{isHovered && <button>编辑</button>}
</div>
);
}如果只是视觉效果,继续用 CSS。
如果悬停状态需要参与 React 渲染、数据请求或者组件通信,再考虑 useHover。
点击元素外部时关闭:useClickOutside
弹窗、下拉菜单、颜色选择器经常需要点击外部后关闭。
import { useEffect } from "react";
import type { RefObject } from "react";
export function useClickOutside<T extends HTMLElement>(
ref: RefObject<T | null>,
onOutsideClick: () => void,
) {
useEffect(() => {
function handlePointerDown(event: PointerEvent) {
const element = ref.current;
if (!element || element.contains(event.target as Node)) {
return;
}
onOutsideClick();
}
document.addEventListener("pointerdown", handlePointerDown);
return () => {
document.removeEventListener("pointerdown", handlePointerDown);
};
}, [ref, onOutsideClick]);
}使用时:
import { useRef, useState } from "react";
function Dropdown() {
const [isOpen, setIsOpen] = useState(false);
const dropdownRef = useRef<HTMLDivElement>(null);
useClickOutside(dropdownRef, () => {
setIsOpen(false);
});
return (
<div ref={dropdownRef}>
<button onClick={() => setIsOpen((value) => !value)}>
切换菜单
</button>
{isOpen && <div>菜单内容</div>}
</div>
);
}这里最容易漏掉的是事件清理:
return () => {
document.removeEventListener("pointerdown", handlePointerDown);
};封装以后,每个下拉菜单不需要重新写一遍。
监听窗口尺寸:useWindowSize
有时 CSS 媒体查询不够用,业务逻辑也需要知道窗口宽度。
import { useEffect, useState } from "react";
type WindowSize = {
width: number;
height: number;
};
export function useWindowSize(): WindowSize {
const [size, setSize] = useState<WindowSize>(() => ({
width: window.innerWidth,
height: window.innerHeight,
}));
useEffect(() => {
function handleResize() {
setSize({
width: window.innerWidth,
height: window.innerHeight,
});
}
window.addEventListener("resize", handleResize);
return () => {
window.removeEventListener("resize", handleResize);
};
}, []);
return size;
}使用时:
function Sidebar() {
const { width } = useWindowSize();
return width >= 1024 ? <DesktopSidebar /> : <MobileSidebar />;
}如果项目有服务端渲染,还要注意:
window在服务端不存在。这时需要提供初始值,或者使用更适合服务端渲染的实现。
监听滚动位置:useScrollPosition
比如页面向下滚动一段距离后,显示返回顶部按钮:
import { useEffect, useState } from "react";
export function useScrollPosition() {
const [scrollY, setScrollY] = useState(() => window.scrollY);
useEffect(() => {
function handleScroll() {
setScrollY(window.scrollY);
}
window.addEventListener("scroll", handleScroll, { passive: true });
return () => {
window.removeEventListener("scroll", handleScroll);
};
}, []);
return scrollY;
}使用时:
function BackToTop() {
const scrollY = useScrollPosition();
if (scrollY < 300) {
return null;
}
return (
<button onClick={() => window.scrollTo({ top: 0, behavior: "smooth" })}>
返回顶部
</button>
);
}如果滚动事件触发太频繁,还可以继续加节流,或者考虑 IntersectionObserver。
第二类:处理数据的 Hook
请求接口:useRequest
网络请求通常不只有一个返回值,还会有几种状态:
- 正在加载。
- 请求成功。
- 请求失败。
- 重新请求。
如果每个页面都自己写,会出现大量重复代码。
可以先写一个简单版本:
import { useCallback, useEffect, useState } from "react";
type RequestState<T> = {
data?: T;
error?: Error;
isLoading: boolean;
};
export function useRequest<T>(
request: (signal: AbortSignal) => Promise<T>,
) {
const [reloadKey, setReloadKey] = useState(0);
const [state, setState] = useState<RequestState<T>>({
isLoading: true,
});
const reload = useCallback(() => {
setReloadKey((value) => value + 1);
}, []);
useEffect(() => {
const controller = new AbortController();
setState((currentState) => ({
data: currentState.data,
isLoading: true,
}));
request(controller.signal)
.then((data) => {
setState({
data,
isLoading: false,
});
})
.catch((error: unknown) => {
if (controller.signal.aborted) {
return;
}
setState({
error: error instanceof Error ? error : new Error(String(error)),
isLoading: false,
});
});
return () => {
controller.abort();
};
}, [request, reloadKey]);
return {
...state,
reload,
};
}使用时:
type User = {
id: number;
name: string;
};
function UserInfo({ userId }: { userId: number }) {
const requestUser = useCallback(
(signal: AbortSignal) => {
return fetch(`/api/users/${userId}`, { signal }).then((response) => {
if (!response.ok) {
throw new Error("请求用户信息失败");
}
return response.json() as Promise<User>;
});
},
[userId],
);
const { data, error, isLoading, reload } = useRequest(
requestUser,
);
if (isLoading) {
return <div>加载中...</div>;
}
if (error) {
return <button onClick={reload}>加载失败,点击重试</button>;
}
return <div>{data?.name}</div>;
}AbortController 用来取消旧请求。
这里用 useCallback() 保持 requestUser 的引用稳定。只有 userId 改变时,函数才会变化并重新触发请求。
例如 userId 很快从 1 变成 2:
请求用户 1
-> 请求用户 2
-> 用户 2 先返回
-> 用户 1 后返回如果不做处理,较慢的旧请求可能覆盖较新的结果。
请求取消可以减少这类竞态问题。
复杂请求优先使用成熟库
上面的 useRequest 适合理解基本思路,也可以覆盖简单场景。
但是实际项目里的请求管理往往还会涉及:
- 缓存。
- 请求去重。
- 分页。
- 自动重试。
- 失焦后重新请求。
- 乐观更新。
- 服务端渲染。
- 多个组件共享请求结果。
如果需求越来越复杂,不要一直手写一个越来越重的 useRequest。
可以考虑使用成熟的数据请求库,或者框架自身提供的数据加载方案。
自定义 Hook 仍然有价值。它可以继续封装项目里的业务接口:
function useUser(userId: number) {
return useQuery({
queryKey: ["user", userId],
queryFn: () => fetchUser(userId),
});
}业务组件只需要:
const userQuery = useUser(userId);防抖值:useDebouncedValue
搜索框很适合用防抖。
用户每输入一个字符就立即请求,可能会产生很多没有必要的网络请求。
import { useEffect, useState } from "react";
export function useDebouncedValue<T>(value: T, delayMs: number) {
const [debouncedValue, setDebouncedValue] = useState(value);
useEffect(() => {
const timer = setTimeout(() => {
setDebouncedValue(value);
}, delayMs);
return () => {
clearTimeout(timer);
};
}, [value, delayMs]);
return debouncedValue;
}使用时:
function SearchBox() {
const [keyword, setKeyword] = useState("");
const debouncedKeyword = useDebouncedValue(keyword, 300);
const result = useSearchResult(debouncedKeyword);
return (
<>
<input
value={keyword}
onChange={(event) => setKeyword(event.target.value)}
/>
<SearchResult data={result.data} />
</>
);
}输入过程中,keyword 会立即更新。
只有停止输入 300ms 后,debouncedKeyword 才会更新。
持久化状态:useLocalStorage
比如保存主题、语言、侧边栏是否折叠:
import { useState } from "react";
export function useLocalStorage<T>(key: string, initialValue: T) {
const [value, setValue] = useState<T>(() => {
const savedValue = localStorage.getItem(key);
if (savedValue === null) {
return initialValue;
}
try {
return JSON.parse(savedValue) as T;
} catch {
return initialValue;
}
});
function updateValue(nextValue: T) {
setValue(nextValue);
localStorage.setItem(key, JSON.stringify(nextValue));
}
return [value, updateValue] as const;
}使用时:
function ThemeSwitch() {
const [theme, setTheme] = useLocalStorage("theme", "light");
return (
<button
onClick={() => {
setTheme(theme === "light" ? "dark" : "light");
}}
>
当前主题:{theme}
</button>
);
}服务端渲染项目同样要注意:
localStorage只存在于浏览器环境中。
监听网络状态:useOnlineStatus
如果应用比较依赖网络,可以监听在线状态:
import { useEffect, useState } from "react";
export function useOnlineStatus() {
const [isOnline, setIsOnline] = useState(() => navigator.onLine);
useEffect(() => {
function handleOnline() {
setIsOnline(true);
}
function handleOffline() {
setIsOnline(false);
}
window.addEventListener("online", handleOnline);
window.addEventListener("offline", handleOffline);
return () => {
window.removeEventListener("online", handleOnline);
window.removeEventListener("offline", handleOffline);
};
}, []);
return isOnline;
}使用时:
function NetworkStatus() {
const isOnline = useOnlineStatus();
return <div>{isOnline ? "网络正常" : "网络已断开"}</div>;
}React 官方文档也使用了类似例子来说明自定义 Hook。
更复杂的生产实现,还要考虑服务端渲染和外部状态订阅。这类场景也可以继续了解 React 的 useSyncExternalStore()。
Hook 不只是减少重复代码
封装以后,调用方会更接近业务描述:
const { isHovered, hoverProps } = useHover();
const debouncedKeyword = useDebouncedValue(keyword, 300);
const isOnline = useOnlineStatus();
const userQuery = useUser(userId);比起在组件中直接放一大段:
useEffect(() => {
// 注册事件
// 开定时器
// 请求数据
// 处理异常
// 清理状态
}, []);Hook 的名字更容易说明代码的意图。
它还有一个好处:容易统一修复问题。
比如 useClickOutside() 忘记处理某个边界条件,只需要修一次,所有使用它的组件都能受益。
Hook 的命名很重要
React 自定义 Hook 的名字应该以 use 开头:
useHover()
useClickOutside()
useWindowSize()
useRequest()
useDebouncedValue()除了满足 React Hook 的规则,名字也应该尽量描述清楚用途。
比较好的名字:
useChatRoom()
useOnlineStatus()
useDeviceConnection()
useFirmwareVersion()不太推荐只包装生命周期:
useMount()
useEffectOnce()React 官方文档也提醒,不要把自定义 Hook 设计成另一套生命周期 API。
比如 useMount() 很容易隐藏依赖问题:
useMount(() => {
connect(roomId);
});如果 roomId 改变了,这段逻辑会不会重新执行?
调用方不容易看出来,Lint 规则也不一定能帮我们发现问题。
更好的做法是按具体用途命名:
useChatRoom(roomId);这样 Hook 内部自己处理依赖和重连逻辑。
注意依赖数组
自定义 Hook 没有让 useEffect 的规则消失。
外部传入的值如果会变化,一般要放进依赖数组。
例如:
export function useDocumentTitle(title: string) {
useEffect(() => {
document.title = title;
}, [title]);
}如果漏掉:
useEffect(() => {
document.title = title;
}, []);组件第一次渲染时标题会更新,但是后续 title 改变时,页面标题不会跟着变化。
封装 Hook 时,要认真处理依赖关系,并启用 React Hooks 相关的 ESLint 规则。
注意事件监听和定时器清理
只要在 useEffect 里注册了外部资源,通常都要考虑清理。
比如:
useEffect(() => {
window.addEventListener("resize", handleResize);
return () => {
window.removeEventListener("resize", handleResize);
};
}, []);还有:
useEffect(() => {
const timer = setInterval(refresh, 1000);
return () => {
clearInterval(timer);
};
}, []);常见需要清理的东西包括:
- DOM 事件监听。
- 定时器。
- 网络请求。
- WebSocket 连接。
- 浏览器观察器。
- 第三方组件实例。
- 硬件设备监听。
自定义 Hook 很适合把“注册”和“清理”放在一起,避免业务组件漏掉其中一半。
不要过度封装
理解到 Hook 的好处以后,也容易什么都想封装一下。
比如:
function useUserName() {
const [userName, setUserName] = useState("");
return [userName, setUserName] as const;
}如果它没有统一规则,没有复用场景,也没有隐藏复杂逻辑,可能还不如直接写:
const [userName, setUserName] = useState("");不是代码重复一行就一定要抽 Hook。
我会优先考虑这些情况:
- 多个组件重复使用同一套状态逻辑。
useEffect比较长,包含订阅和清理。- 逻辑有明确用途,可以取一个清楚的名字。
- 业务组件已经被实现细节盖住。
- 以后很可能统一修复或升级实现。
UI 组件和 Hook 怎么分工
可以简单区分:
| 类型 | 更适合封装什么 |
|---|---|
| UI 组件 | 结构、样式、可视化内容 |
| 自定义 Hook | 状态、事件、副作用、数据处理 |
| 普通函数 | 不依赖 React 状态的纯计算 |
比如:
function formatPrice(value: number) {
return `¥${value.toFixed(2)}`;
}这个函数不需要 useState 或 useEffect,就不要写成:
useFormatPrice()普通函数已经够用了。
我最后想通的地方
刚开始写 React 时,我会把很多逻辑直接塞进业务组件:
监听事件
-> 更新状态
-> 发请求
-> 处理加载中
-> 处理错误
-> 清理事件代码能运行,但是组件会越来越难读。
后来开始使用自定义 Hook,我发现它的作用不是简单地把代码搬到另一个文件。
它是在给一段状态逻辑起名字:
useHover()
useClickOutside()
useDebouncedValue()
useOnlineStatus()
useUser()业务组件只需要关心自己需要什么能力,不需要每次重新理解底层怎么实现。
就像小型 UI 组件一样,自定义 Hook 也不一定很复杂。
只要它能表达清楚用途,隐藏重复细节,统一处理边界条件,让业务代码更容易维护,它就值得存在。