前言
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 什么时候运行。
依赖数组的三种情况
- 不传依赖数组:每次渲染后都执行。适合那些真正需要在每次更新后跑的逻辑,但容易造成性能问题或死循环。
useEffect(() => {
console.log('每次渲染后都打印');
});
- 空数组 [] :只在组件挂载(mount)时执行一次,卸载(unmount)时清理。相当于类组件的 componentDidMount + componentWillUnmount。
useEffect(() => {
console.log('只在挂载时执行一次');
}, []);
组件挂载:组件“出生”并“上台亮相”,这时可以安全地做一些初始化操作,比如:
- 发请求加载数据
- 添加事件监听(window.addEventListener)
- 启动定时器
- 操作 DOM
组件卸载:组件“死亡”并“下台”,这时必须做清理工作,避免内存泄漏,比如:
- 清除定时器(clearInterval)
- 移除事件监听(removeEventListener)
- 取消网络请求
- 取消订阅(WebSocket、Observable)
- 带依赖的数组:依赖项变化时执行。完美对应 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 变化多次)会出现两个主要问题:
-
定时器越积越多(内存泄漏)
- 每次点击,num 改变 → useEffect 重新执行 → 新建一个新的定时器。
- 但旧的定时器没有被清除。
- 结果:点击几次,就会有几个定时器同时每秒打印一次,点击越多,打印越快,控制台被刷爆,严重时还会占用大量内存。
-
打印的 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 是“幂等”的(重复执行无害),就没问题。
常见坑 & 最佳实践
- 依赖数组写错:漏依赖会导致陈旧值;多依赖会导致不必要执行。听 ESLint 的警告,别随便 disable。
- 在 effect 里直接 setState 同一个值:容易死循环。
- 定时器/订阅没清理:内存泄漏。
- 把事件处理逻辑放 effect:比如点击购买,应该直接在 onClick 里处理,别放 effect。
- 条件渲染里子组件的 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。