← Back to desk

Archive Note

React Hook 学习 4:从 useState、useEffect 到自定义 Hooks

Filed As react-hook-practice

单独理解 useStateuseEffectuseRef 还不够。真实开发里,难点往往不在于“这个 Hook 怎么用”,而在于:

一个业务功能应该怎样拆成清晰的状态、同步逻辑和可复用逻辑。

这一篇的重点,就是把前 3 篇连起来,变成真正能落到项目里的 Hook 设计能力。

一、先分清 3 类东西

一个 React 功能模块,通常会同时包含三类信息:

1. 渲染状态

这些值决定 UI 怎么显示。

例如:

  • 是否打开弹窗
  • 当前表单值
  • 当前列表数据
  • loading / error

这些应该优先考虑 useStateuseReducer

2. 副作用同步

这些逻辑负责和外部世界交互。

例如:

  • 请求接口
  • 注册事件
  • 订阅数据源
  • 同步浏览器 API

这些通常落在 useEffect

3. 非渲染记忆

这些值需要跨渲染保存,但不该触发 UI 更新。

例如:

  • 定时器 id
  • 上一次值
  • 第三方实例
  • 最新回调引用

这些通常用 useRef

一旦你把这三类东西混在一起,组件就会开始难维护。

二、真实业务例子:搜索框

假设你在做一个搜索框。

第一层:渲染状态

const [keyword, setKeyword] = useState("");
const [results, setResults] = useState<Item[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);

第二层:副作用同步

useEffect(() => {
  if (!keyword.trim()) {
    setResults([]);
    return;
  }

  let active = true;

  async function runSearch() {
    setIsLoading(true);
    setError(null);

    try {
      const response = await fetch(`/api/search?q=${encodeURIComponent(keyword)}`);
      const data = await response.json();

      if (active) {
        setResults(data);
      }
    } catch (err) {
      if (active) {
        setError("Search failed");
      }
    } finally {
      if (active) {
        setIsLoading(false);
      }
    }
  }

  runSearch();

  return () => {
    active = false;
  };
}, [keyword]);

第三层:非渲染记忆

如果你后面要加 debounce,就可能需要:

const timerRef = useRef<number | null>(null);

这就是一个典型的 Hook 组合场景。

三、什么时候应该提取自定义 Hook

很多人提取自定义 Hook 太早,结果只是把复杂度换了个文件名。

更好的判断标准是:

  • 这段逻辑是否在多个组件中重复出现
  • 这段逻辑是否已经形成清晰职责
  • 抽出去后,组件会不会更容易理解

例如一个搜索功能,最终可以抽成:

function useSearch() {
  // keyword
  // results
  // loading
  // effect
  // handlers
}

这时候页面组件只关心:

  • 输入框怎么渲染
  • 结果列表怎么渲染

而不再被一堆请求和状态细节淹没。

四、自定义 Hook 不是“高级语法”,而是逻辑封装

很多人学自定义 Hook 时,会误以为它是 React 的某种特殊能力。

其实本质上它只是:

把一段基于 Hook 的可复用逻辑,包装成一个函数。

例如:

function useWindowWidth() {
  const [width, setWidth] = useState(window.innerWidth);

  useEffect(() => {
    const handleResize = () => setWidth(window.innerWidth);
    window.addEventListener("resize", handleResize);
    return () => window.removeEventListener("resize", handleResize);
  }, []);

  return width;
}

页面里直接用:

const width = useWindowWidth();

这就是把状态、effect、cleanup 组合成了一个更高层次的抽象。

五、什么时候该用 useMemo / useCallback

这两个 Hook 很常见,但也最容易被滥用。

useMemo

适合缓存昂贵计算结果,避免每次 render 都重算。

useCallback

适合缓存函数引用,避免因为引用变化导致子组件无意义更新。

但真正的判断标准不是“项目里大家都在用”,而是:

  • 这里真的有性能问题吗?
  • 子组件真的在依赖稳定引用吗?
  • 这个优化的复杂度值不值得?

如果没有明确收益,不要机械地给每个函数都套 useCallback

六、真实开发里最常见的组合错误

错误 1:把派生值也存进 state

会制造无意义同步。

错误 2:把事件逻辑塞进 effect

很多逻辑本来应该在按钮点击时执行,却被硬塞到 effect 里,导致依赖关系越来越乱。

错误 3:为了绕过依赖数组,把所有东西塞进 ref

这会让代码“看起来不报错”,但实际数据流变得很难理解。

错误 4:过早抽自定义 Hook

如果逻辑本身还没稳定,抽出去只会让调试更麻烦。

七、一个更稳的 Hook 设计顺序

我更推荐这样做:

  1. 先写清楚组件需要哪些渲染状态
  2. 再识别哪些逻辑属于副作用
  3. 再识别哪些值只是运行时记忆
  4. 当逻辑职责稳定后,再提取自定义 Hook

这个顺序会比“一上来就抽象”稳很多。

八、这一篇最该记住的结论

  • useState 管渲染状态
  • useEffect 管外部同步
  • useRef 管非渲染记忆
  • 自定义 Hook 的价值在于封装职责清晰、可复用的逻辑
  • useMemo / useCallback 是优化工具,不是默认配置

真正好的 React Hook 使用方式,不是把 API 背熟,而是能在一个功能里清楚地区分:

  • 哪些数据驱动 UI
  • 哪些逻辑是副作用
  • 哪些值只是内部记忆
  • 哪些东西值得提取成抽象

这才是从“会写 Hook”到“能用 Hook 设计组件”的区别。