React Hooks 学习:深入理解 useState 和 useEffect

87 阅读9分钟

前言

React Hooks 的出现,让函数组件彻底摆脱了类组件的束缚,直接在函数里管理状态和副作用。尤其是 useState 和 useEffect 这两个最常用的 Hooks,几乎是每个 React 项目的基础。今天我们就从零开始,结合实际代码,一步步聊聊它们怎么用、常见坑在哪里,以及怎么写出更靠谱的代码。

一、useState:让函数组件有“记忆”

在类组件时代,我们靠 this.state 和 this.setState 来管理状态。Hooks 来了后,一切都简单了:useState 直接返回一个状态值和一个更新函数。

useState 是 React 最常用的 Hook 之一,它让函数组件能够拥有自己的状态(state),实现数据变化时自动更新 UI。

基本结构

import { useState } from 'react';

const [state, setState] = useState(initialValue);
  • state:当前的状态值(可以是数字、字符串、对象、数组等)。
  • setState:更新状态的函数,调用它会触发组件重新渲染。
  • initialValue:状态的初始值。可以是直接的值,也可以是一个返回初始值的函数(用于懒初始化)。

基本用法超级直白:

import { useState } from 'react';

function Counter() {
  const [count, setCount] = useState(0);

  return (
    <div>
      <p>当前计数:{count}</p>
      <button onClick={() => setCount(count + 1)}>加一</button>
    </div>
  );
}

点击按钮,count 就加 1,组件重新渲染,页面更新。这就是 useState 的魔力——它给了函数组件“记忆”能力。

初始值可以是函数:懒初始化

如果你初始值需要一些计算,比如从 props 里算点东西,或者生成一个大对象,直接传值每次渲染都会重新计算一遍(虽然 React 会忽略后续渲染的初始值,但计算还是白跑了)。

这时候传一个函数就行,React 只会在组件第一次挂载时调用它:

const [num, setNum] = useState(() => {
  const num1 = 1 + 2;
  const num2 = 3 + 4;
  return num1 + num2; // 只有第一次渲染时计算
});

为什么这么设计?因为状态初始化有时候挺“贵”的,比如读取本地存储、复杂计算。用函数包裹,就能避免不必要的开销。

注意:这个初始化函数是纯函数,不能有副作用(比如发请求),也不能接受参数。

纯函数(Pure Function) 是函数式编程中的一个核心概念,指的是满足以下两个条件的函数:

1). 相同的输入,总是产生相同的输出

无论何时、何地调用该函数,只要输入参数相同,返回的结果就一定相同。

2). 没有副作用(Side Effects)

函数在执行过程中不会:

  • 修改外部变量或全局状态;
  • 修改传入的参数(如修改对象或数组的内容);
  • 进行 I/O 操作(如读写文件、打印日志、发送网络请求);
  • 抛出异常(某些定义中认为这也算副作用);
  • 依赖或改变外部可变状态(比如当前时间 Date.now()Math.random() 等)。

React 官方文档明确指出:

如果你传一个函数给 useState 作为 initialState,它会被当作 initializer function(初始化函数)。 这个函数 应该纯净(pure)不接受参数、返回任意类型的值。 React 只在组件初始渲染时调用它,并存储返回值作为初始状态。

在开发模式下(StrictMode 开启时),React 会故意调用这个初始化函数两次,来帮你检测它是否纯净。如果不是纯函数(有副作用、返回不同值),就会导致问题:

  • 返回值不一致 → 初始状态不确定
  • 有副作用(如发请求、修改全局变量) → 副作用执行两次,造成 bug(如重复请求)

生产环境不会调用两次,但为了代码健壮,最好始终保持纯净。

更新状态的两种方式

setCount 可以直接传新值:

setCount(666);

也可以传一个函数,这个函数接收上一次的状态作为参数,返回新状态:

setCount(prev => prev + 1);

第二种方式特别有用,尤其是在连续多次更新同一个状态时。React 会把这些更新排队,确保每次都基于最新的状态计算,避免“陈旧闭包”问题。

举个例子:

function BadExample() {
  const [count, setCount] = useState(0);

  const handleClick = () => {
    setCount(count + 1); // 这里 count 是点击时的旧值
    setCount(count + 1); // 还是同一个旧值!结果只加了 1
  };
}

“陈旧闭包”本质上是闭包捕获了渲染时的状态快照 + React 批量更新不立即重渲染 共同导致的。 用 setState(prev => ...) 就能优雅解决,因为 React 会帮你把更新串联起来,始终基于最新状态计算。

改成函数形式就稳了:

const handleClick = () => {
  setCount(prev => prev + 1);
  setCount(prev => prev + 1); // 两次都基于最新值,结果加 2
};

useState 能干嘛?不能干嘛?

  • 能:管理任何数据——数字、字符串、对象、数组。
  • 不能:直接支持异步初始化(比如组件挂载时发请求拿初始数据)。这时候就需要配合 useEffect。

二、useEffect:处理“副作用”

React 组件的核心是“纯渲染”:给定 props 和 state,输出 JSX。但现实中总有些事不是纯渲染能搞定的,比如:

  • 发请求拿数据
  • 订阅事件(WebSocket、事件监听)
  • 操作 DOM
  • 设置定时器

这些叫“副作用”(side effects),useEffect 就是专门干这个的。

基本结构:

import { useEffect } from 'react';

useEffect(() => {
  // 这里放副作用代码(主逻辑)

  return () => {
    // 可选:清理函数(cleanup),用于收尾工作
  };
}, [依赖数组]);  // 依赖项,控制何时执行
  • 第一个参数:一个函数(effect 函数),里面写副作用逻辑。

  • 可选返回:一个清理函数,在组件卸载或下次 effect 执行前运行。

  • 第二个参数:依赖数组(dependency array),决定 effect 什么时候运行。

依赖数组的三种情况

  1. 不传依赖数组:每次渲染后都执行。适合那些真正需要在每次更新后跑的逻辑,但容易造成性能问题或死循环。
useEffect(() => {
  console.log('每次渲染后都打印');
});

  1. 空数组 [] :只在组件挂载(mount)时执行一次,卸载(unmount)时清理。相当于类组件的 componentDidMount + componentWillUnmount。

useEffect(() => {
  console.log('只在挂载时执行一次');
}, []);

组件挂载:组件“出生”并“上台亮相”,这时可以安全地做一些初始化操作,比如:

  • 发请求加载数据
  • 添加事件监听(window.addEventListener)
  • 启动定时器
  • 操作 DOM

组件卸载:组件“死亡”并“下台”,这时必须做清理工作,避免内存泄漏,比如:

  • 清除定时器(clearInterval)
  • 移除事件监听(removeEventListener)
  • 取消网络请求
  • 取消订阅(WebSocket、Observable)

  1. 带依赖的数组:依赖项变化时执行。完美对应 componentDidUpdate。
useEffect(() => {
  console.log('num 变了才执行', num);
}, [num]);

依赖数组用 Object.is 比较,记得把 effect 里用到的所有变量都放进去(ESLint 的 react-hooks/exhaustive-deps 规则会帮你检查)。

清理函数:避免内存泄漏的关键

很多副作用需要“收尾”,比如:

  • 添加了事件监听,要移除
  • 开了定时器,要清除
  • 订阅了数据,要取消订阅
  • 发了请求,组件卸载前要 abort

useEffect 支持返回一个清理函数:

useEffect(() => {
  const timer = setInterval(() => {
    console.log(num);
  }, 1000);
}, [num]);

多次点击按钮(num 变化多次)会出现两个主要问题

  1. 定时器越积越多(内存泄漏)

    • 每次点击,num 改变 → useEffect 重新执行 → 新建一个新的定时器
    • 旧的定时器没有被清除
    • 结果:点击几次,就会有几个定时器同时每秒打印一次,点击越多,打印越快,控制台被刷爆,严重时还会占用大量内存。
  2. 打印的 num 值不是最新的

    • 每个定时器在创建时,会“记住”当时那一刻的 num 值(闭包)。
    • 所以不同的定时器会分别打印自己记住的那个旧值,而不是当前最新的 num。

正确简单的修复方法

在 useEffect 里返回一个清理函数,用来清除上一次的定时器:

useEffect(() => {
  const timer = setInterval(() => {
    console.log(num);
  }, 1000);

  // 关键:返回清理函数
  return () => {
    clearInterval(timer);
  };
}, [num]);

这样:

  • num 每次变化,先清除旧定时器,再创建新定时器。
  • 永远只有一个定时器在运行。
  • 每秒打印的都是当前最新的 num。
  • 组件卸载时也会自动清理,不会漏内存。

清理函数会在两种时机运行:

  • 组件卸载时(最后一次)
  • 依赖变化、下次 effect 执行前

这能有效防止内存泄漏。比如经典的“组件卸载后还在 setState”警告,就是没清理导致的。

异步请求的正确姿势

useState 初始化时想异步拿数据怎么办?直接在 useState 里不行,因为它不支持 async。

标准做法:在 useEffect 里发请求:

useEffect(() => {
  let ignore = false; // 防止竞态

  async function fetchData() {
    const res = await fetch('/api/data');
    const data = await res.json();
    if (!ignore) setNum(data);
  }

  fetchData();

  return () => { ignore = true; }; // 清理:如果组件卸载,忽略响应
}, []);

或者用 AbortController abort 请求。更现代的做法是用 TanStack Query 这种库,自动处理取消、缓存、重试。

React 18 的“双调用”现象

如果你用 React 18 + StrictMode 开发,会发现带空依赖的 useEffect 执行了两次(开发环境独有)。

这是故意的!React 在开发模式下会故意挂载 → 卸载 → 重新挂载组件,来模拟未来可能的离屏渲染特性,同时帮你检查清理函数是否写对。

生产环境不会这样。只要你的 effect 和 cleanup 是“幂等”的(重复执行无害),就没问题。

常见坑 & 最佳实践

  1. 依赖数组写错:漏依赖会导致陈旧值;多依赖会导致不必要执行。听 ESLint 的警告,别随便 disable。
  2. 在 effect 里直接 setState 同一个值:容易死循环。
  3. 定时器/订阅没清理:内存泄漏。
  4. 把事件处理逻辑放 effect:比如点击购买,应该直接在 onClick 里处理,别放 effect。
  5. 条件渲染里子组件的 effect:子组件卸载时记得清理。

最后:一个小完整例子

function App() {
  const [num, setNum] = useState(0);
  const [data, setData] = useState(null);

  useEffect(() => {
    const timer = setInterval(() => {
      setNum(prev => prev + 1);
    }, 1000);

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

  useEffect(() => {
    let ignore = false;
    fetch(`/api/get?num=${num}`)
      .then(res => res.json())
      .then(res => {
        if (!ignore) setData(res);
      });
    return () => { ignore = true };
  }, [num]);

  return (
    <div>
      <p>计数:{num}</p>
      <p>数据:{data ? JSON.stringify(data) : '加载中...'}</p>
    </div>
  );
}

这个例子展示了定时器、依赖变化发请求、清理竞态的全流程。

写在最后

useState 和 useEffect 是 React Hooks 的基石,用好了能让代码简洁、可预测。用不好就容易踩坑:状态错乱、内存泄漏、无限循环。

多写多练,配合 ESLint 的 react-hooks 规则,慢慢就会养成好习惯。记住一句话:保持渲染纯净,把副作用交给 useEffect