React

用自定义 Hook 复用 React 里的状态逻辑

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

2026年6月13日23 分钟阅读
用自定义 Hook 复用 React 里的状态逻辑

这篇笔记记录一下我在工作中逐渐开始使用 React 自定义 Hook 的过程。

写 React 页面时,经常会碰到一些重复逻辑。

有些逻辑和 UI 交互有关,比如:

  • 鼠标是否移动到某个元素上。
  • 点击元素外部时关闭弹窗。
  • 窗口宽度变化时更新布局。
  • 页面滚动到某个位置后显示返回顶部按钮。

还有一些逻辑和数据处理有关,比如:

  • 请求接口并维护加载状态。
  • 搜索输入停止一段时间后再发请求。
  • 把状态保存到 localStorage
  • 监听网络是否断开。

这些逻辑都可以直接写在业务组件里。

但是写多了以后,组件里会出现很多 useStateuseEffect、事件监听和清理函数。页面真正想表达的业务内容,反而被这些细节盖住了。

这时可以把通用逻辑封装成自定义 Hook。

自定义 Hook 是什么

自定义 Hook 本质上还是一个普通函数。

它的名字以 use 开头,内部可以调用 React 提供的 Hook:

ts
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),
  };
}

使用时:

tsx
function Demo() {
  const modal = useBoolean();

  return (
    <>
      <button onClick={modal.open}>打开弹窗</button>

      {modal.value && (
        <div>
          弹窗内容
          <button onClick={modal.close}>关闭</button>
        </div>
      )}
    </>
  );
}

自定义 Hook 复用的是状态逻辑,不是同一份状态。

如果两个组件分别调用:

ts
const modal = useBoolean();

它们会得到两份互相独立的状态。

为什么要封装 Hook

看一个没有封装的例子:

tsx
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}`}
    />
  );
}

代码没有错。

但是业务组件里混进了防抖计时器的实现细节。

封装成:

ts
const debouncedKeyword = useDebouncedValue(keyword, 300);

以后,业务组件只需要表达:

text
我需要一个延迟更新的 keyword

而不用每次重新写定时器和清理逻辑。

我觉得自定义 Hook 的价值有点像前面提到的 Flex 组件:

  • Flex 把样式细节整理成更清楚的布局语义。
  • 自定义 Hook 把状态和副作用细节整理成更清楚的行为语义。

第一类:处理 UI 交互的 Hook

鼠标移动到元素上:useHover

CSS 本身有 :hover

css
.button:hover {
  background: blue;
}

如果只是改颜色,CSS 已经够用了。

但是有些场景需要把“鼠标是否悬停”传进 React 状态:

  • 鼠标移动到卡片上时,显示额外按钮。
  • 悬停时切换图表数据。
  • 悬停时加载预览内容。
  • 父组件需要知道子元素是否处于悬停状态。

这时可以写一个 useHover

ts
import { useState } from "react";

export function useHover() {
  const [isHovered, setIsHovered] = useState(false);

  return {
    isHovered,
    hoverProps: {
      onMouseEnter: () => setIsHovered(true),
      onMouseLeave: () => setIsHovered(false),
    },
  };
}

使用时:

tsx
function UserCard() {
  const { isHovered, hoverProps } = useHover();

  return (
    <div {...hoverProps}>
      <span>用户信息</span>
      {isHovered && <button>编辑</button>}
    </div>
  );
}

如果只是视觉效果,继续用 CSS。

如果悬停状态需要参与 React 渲染、数据请求或者组件通信,再考虑 useHover

点击元素外部时关闭:useClickOutside

弹窗、下拉菜单、颜色选择器经常需要点击外部后关闭。

ts
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]);
}

使用时:

tsx
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>
  );
}

这里最容易漏掉的是事件清理:

ts
return () => {
  document.removeEventListener("pointerdown", handlePointerDown);
};

封装以后,每个下拉菜单不需要重新写一遍。

监听窗口尺寸:useWindowSize

有时 CSS 媒体查询不够用,业务逻辑也需要知道窗口宽度。

ts
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;
}

使用时:

tsx
function Sidebar() {
  const { width } = useWindowSize();

  return width >= 1024 ? <DesktopSidebar /> : <MobileSidebar />;
}

如果项目有服务端渲染,还要注意:

ts
window

在服务端不存在。这时需要提供初始值,或者使用更适合服务端渲染的实现。

监听滚动位置:useScrollPosition

比如页面向下滚动一段距离后,显示返回顶部按钮:

ts
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;
}

使用时:

tsx
function BackToTop() {
  const scrollY = useScrollPosition();

  if (scrollY < 300) {
    return null;
  }

  return (
    <button onClick={() => window.scrollTo({ top: 0, behavior: "smooth" })}>
      返回顶部
    </button>
  );
}

如果滚动事件触发太频繁,还可以继续加节流,或者考虑 IntersectionObserver

第二类:处理数据的 Hook

请求接口:useRequest

网络请求通常不只有一个返回值,还会有几种状态:

  • 正在加载。
  • 请求成功。
  • 请求失败。
  • 重新请求。

如果每个页面都自己写,会出现大量重复代码。

可以先写一个简单版本:

ts
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,
  };
}

使用时:

tsx
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

text
请求用户 1
  -> 请求用户 2
  -> 用户 2 先返回
  -> 用户 1 后返回

如果不做处理,较慢的旧请求可能覆盖较新的结果。

请求取消可以减少这类竞态问题。

复杂请求优先使用成熟库

上面的 useRequest 适合理解基本思路,也可以覆盖简单场景。

但是实际项目里的请求管理往往还会涉及:

  • 缓存。
  • 请求去重。
  • 分页。
  • 自动重试。
  • 失焦后重新请求。
  • 乐观更新。
  • 服务端渲染。
  • 多个组件共享请求结果。

如果需求越来越复杂,不要一直手写一个越来越重的 useRequest

可以考虑使用成熟的数据请求库,或者框架自身提供的数据加载方案。

自定义 Hook 仍然有价值。它可以继续封装项目里的业务接口:

ts
function useUser(userId: number) {
  return useQuery({
    queryKey: ["user", userId],
    queryFn: () => fetchUser(userId),
  });
}

业务组件只需要:

ts
const userQuery = useUser(userId);

防抖值:useDebouncedValue

搜索框很适合用防抖。

用户每输入一个字符就立即请求,可能会产生很多没有必要的网络请求。

ts
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;
}

使用时:

tsx
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

比如保存主题、语言、侧边栏是否折叠:

ts
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;
}

使用时:

tsx
function ThemeSwitch() {
  const [theme, setTheme] = useLocalStorage("theme", "light");

  return (
    <button
      onClick={() => {
        setTheme(theme === "light" ? "dark" : "light");
      }}
    >
      当前主题:{theme}
    </button>
  );
}

服务端渲染项目同样要注意:

ts
localStorage

只存在于浏览器环境中。

监听网络状态:useOnlineStatus

如果应用比较依赖网络,可以监听在线状态:

ts
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;
}

使用时:

tsx
function NetworkStatus() {
  const isOnline = useOnlineStatus();

  return <div>{isOnline ? "网络正常" : "网络已断开"}</div>;
}

React 官方文档也使用了类似例子来说明自定义 Hook。

更复杂的生产实现,还要考虑服务端渲染和外部状态订阅。这类场景也可以继续了解 React 的 useSyncExternalStore()

Hook 不只是减少重复代码

封装以后,调用方会更接近业务描述:

ts
const { isHovered, hoverProps } = useHover();
const debouncedKeyword = useDebouncedValue(keyword, 300);
const isOnline = useOnlineStatus();
const userQuery = useUser(userId);

比起在组件中直接放一大段:

ts
useEffect(() => {
  // 注册事件
  // 开定时器
  // 请求数据
  // 处理异常
  // 清理状态
}, []);

Hook 的名字更容易说明代码的意图。

它还有一个好处:容易统一修复问题。

比如 useClickOutside() 忘记处理某个边界条件,只需要修一次,所有使用它的组件都能受益。

Hook 的命名很重要

React 自定义 Hook 的名字应该以 use 开头:

ts
useHover()
useClickOutside()
useWindowSize()
useRequest()
useDebouncedValue()

除了满足 React Hook 的规则,名字也应该尽量描述清楚用途。

比较好的名字:

ts
useChatRoom()
useOnlineStatus()
useDeviceConnection()
useFirmwareVersion()

不太推荐只包装生命周期:

ts
useMount()
useEffectOnce()

React 官方文档也提醒,不要把自定义 Hook 设计成另一套生命周期 API。

比如 useMount() 很容易隐藏依赖问题:

ts
useMount(() => {
  connect(roomId);
});

如果 roomId 改变了,这段逻辑会不会重新执行?

调用方不容易看出来,Lint 规则也不一定能帮我们发现问题。

更好的做法是按具体用途命名:

ts
useChatRoom(roomId);

这样 Hook 内部自己处理依赖和重连逻辑。

注意依赖数组

自定义 Hook 没有让 useEffect 的规则消失。

外部传入的值如果会变化,一般要放进依赖数组。

例如:

ts
export function useDocumentTitle(title: string) {
  useEffect(() => {
    document.title = title;
  }, [title]);
}

如果漏掉:

ts
useEffect(() => {
  document.title = title;
}, []);

组件第一次渲染时标题会更新,但是后续 title 改变时,页面标题不会跟着变化。

封装 Hook 时,要认真处理依赖关系,并启用 React Hooks 相关的 ESLint 规则。

注意事件监听和定时器清理

只要在 useEffect 里注册了外部资源,通常都要考虑清理。

比如:

ts
useEffect(() => {
  window.addEventListener("resize", handleResize);

  return () => {
    window.removeEventListener("resize", handleResize);
  };
}, []);

还有:

ts
useEffect(() => {
  const timer = setInterval(refresh, 1000);

  return () => {
    clearInterval(timer);
  };
}, []);

常见需要清理的东西包括:

  • DOM 事件监听。
  • 定时器。
  • 网络请求。
  • WebSocket 连接。
  • 浏览器观察器。
  • 第三方组件实例。
  • 硬件设备监听。

自定义 Hook 很适合把“注册”和“清理”放在一起,避免业务组件漏掉其中一半。

不要过度封装

理解到 Hook 的好处以后,也容易什么都想封装一下。

比如:

ts
function useUserName() {
  const [userName, setUserName] = useState("");
  return [userName, setUserName] as const;
}

如果它没有统一规则,没有复用场景,也没有隐藏复杂逻辑,可能还不如直接写:

ts
const [userName, setUserName] = useState("");

不是代码重复一行就一定要抽 Hook。

我会优先考虑这些情况:

  • 多个组件重复使用同一套状态逻辑。
  • useEffect 比较长,包含订阅和清理。
  • 逻辑有明确用途,可以取一个清楚的名字。
  • 业务组件已经被实现细节盖住。
  • 以后很可能统一修复或升级实现。

UI 组件和 Hook 怎么分工

可以简单区分:

类型更适合封装什么
UI 组件结构、样式、可视化内容
自定义 Hook状态、事件、副作用、数据处理
普通函数不依赖 React 状态的纯计算

比如:

ts
function formatPrice(value: number) {
  return ${value.toFixed(2)}`;
}

这个函数不需要 useStateuseEffect,就不要写成:

ts
useFormatPrice()

普通函数已经够用了。

我最后想通的地方

刚开始写 React 时,我会把很多逻辑直接塞进业务组件:

text
监听事件
  -> 更新状态
  -> 发请求
  -> 处理加载中
  -> 处理错误
  -> 清理事件

代码能运行,但是组件会越来越难读。

后来开始使用自定义 Hook,我发现它的作用不是简单地把代码搬到另一个文件。

它是在给一段状态逻辑起名字:

ts
useHover()
useClickOutside()
useDebouncedValue()
useOnlineStatus()
useUser()

业务组件只需要关心自己需要什么能力,不需要每次重新理解底层怎么实现。

就像小型 UI 组件一样,自定义 Hook 也不一定很复杂。

只要它能表达清楚用途,隐藏重复细节,统一处理边界条件,让业务代码更容易维护,它就值得存在。