useState 真的那么简单吗?我在项目里踩过的坑

我敢打赌,你一定遇到过这种情况:
某天下午,同事在群里问:"咋回事啊,用户点了按钮,状态改了,但列表没更新啊?"
你开始调试,F12 打开,state 里的数据明明改了,UI 就是没反应。折腾半小时,最后发现——是直接改了数组,没有创建新对象。
或者这样:你看到前辈写的代码里,useState 十来个,各种奇怪的副作用代码到处都是,改一个字段牵一发动全身。最后索性不敢动,怕出bug。
我也是这样过来的。慢慢才明白,useState 看似简单,但很多人用了好几年也没真正吃透。
我刚工作时的懵逼时刻
那时候我对 useState 的理解就是:"就是个变量呗,用 setCount 改改值。"
const [count, setCount] = useState(0);function handleClick() {setCount(count + 1);console.log(count); // 我以为会是 1
}结果呢?还是 0。
我就很纳闷啊,为什么我改了还是 0?我甚至问过 ChatGPT(那时候还没有,我查的文档)。文档里说"状态更新是异步的",我当时理解得一知半解——"异步","什么鬼?为什么要异步?"
后来在大佬的指点下才明白:
React 不是你改了就立刻生效的。 你调用 setCount 时,你只是告诉 React:"嘿,请帮我把这个事儿加到待办清单里。" React 会合并你的所有更新,一起处理,然后才重新渲染页面。
当前这一帧里,count 的值是冻住的。你读不到新值。只有下一帧重新渲染时,你才能看到新的 count。
想象一下,你在银行存钱。你告诉柜员:"我要存 100 块。" 柜员记录下来,然后排队处理了你和其他 10 个人的请求,一起更新系统。你不能指望她立刻告诉你新余额——还得等系统更新完。
setState 之后立刻读值?真正的坑来了
我第一次被这个坑害惨了。那是一个下午,做一个秒杀活动的功能:
function handleBuy() {// 用户点击"立即下单"setCount(count - 1);// 我以为这里 count 已经改了if (count <= 0) {// 库存不足,弹窗提示alert('已售罄');}
}结果呢?用户一直能点"下单",count 怎么都减不完。
因为每次我判断的 count,都是上一帧的值。我在 setState 之后立刻判断,用的还是旧的 count。
后来在测试提bug的时候才发现——这和我们的库存系统不一致!
我学到的第一课:不要试图在 setState 之后立刻用新值。把你的逻辑分开。如果你需要根据新状态做什么事,放到下一个组件渲染周期里,或者用 useEffect。
// 正确的做法
function handleBuy() {
const newCount = count - 1;setCount(newCount);// 不在这里判断,而是在组件渲染时判断
}// 或者用 useEffect 监听
useEffect(() => {
if (count <= 0) {alert('已售罄');}
}, [count]);多次 setState,为什么只有最后一个生效?
我还遇到过更尴尬的事儿。
我们的活动页面有个礼券码兑换的功能。用户输入一个优惠码,我需要做三件事:
验证码的有效性
获取折扣信息
更新用户的优惠券列表
我一开始这样写的:
function redeemCoupon(code) {setIsPending(true);setError(null);setDiscount(null);// 验证并获取
const result = await validateCoupon(code);if (result.success) {setCoupons(result.coupons); // 更新券列表setDiscount(result.discount); // 设置折扣} else {setError(result.message); // 设置错误}setIsPending(false);
}这看起来没问题,但实际场景更复杂。后来我们发现,如果用户快速操作(网络稍微慢一点),前面的状态会被后面的覆盖。
我就很郁闷啊,明明设置了啊,为什么没有?
真相是:我多次调用 setXxx,React 会合并这些更新。但每次都用的是同一个快照的旧值。
比如说,我有个递增的需求:
function increment() {setCount(count + 1);setCount(count + 1);setCount(count + 1);
}结果只加了 1,不是 3。因为三行代码用的都是同一个 count 值。相当于:
setCount(5 + 1); // 6
setCount(5 + 1); // 还是 6
setCount(5 + 1); // 还是 6,最后取最后一个后来我的前辈教我用函数式更新:
function increment() {setCount(c => c + 1); // React 给我最新的值setCount(c => c + 1); // React 再给我新的值setCount(c => c + 1); // React 再给我新的值
}这样每次 React 都会把最新的值传给我,我这样操作就行了:
setCount(c => c + 1);这个单独一行,看似简单,但威力巨大。
从"列表管理"学到的状态分组智慧
我们有个后台系统,要管理一个用户列表。一开始我就这样做:
const [users, setUsers] = useState([]);
const [totalCount, setTotalCount] = useState(0);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState(null);
const [currentPage, setCurrentPage] = useState(1);五个 state,感觉很"完善"。
但问题来了。有一次,我在删除用户的时候,更新了 users 列表,但忘了更新 totalCount。结果用户界面显示的总数和实际列表数不符。
我坐在那里狂敲代码调试,最后发现——我在三个地方都需要同步这两个值。改一个忘一个。
后来我看到老大哥怎么做的,才恍然大悟:
// 按照"更新频率"和"相关性"分组
const [listData, setListData] = useState({
users: [],
totalCount: 0
});const [pagination, setPagination] = useState({
currentPage: 1,
pageSize: 10
});const [uiState, setUiState] = useState({
isLoading: false,
error: null
});这样分组的好处是:
listData 总是一起更新,你不会忘记同步 users 和 totalCount
pagination 独立变化,改页码时不会影响其他
uiState 是通用的加载态和错误态,可以复用
更新时就变得清晰了:
function fetchUsers() {setUiState({ isLoading: true, error: null });api.getUsers(pagination.currentPage).then(res => {setListData({users: res.data,totalCount: res.total});setUiState({ isLoading: false, error: null });}).catch(err => {setUiState({ isLoading: false, error: err.message });});
}再也不会出现数据不一致的问题了。
那个"衍生数据"的坑,差点被我重复踩
有一次,我在做一个购物车页面。用户可以添加/删除商品,我需要显示:
购物车里的商品列表
购物车总价
商品数量
一开始我"聪明"地这样做:
const [items, setItems] = useState([]);
const [totalPrice, setTotalPrice] = useState(0);
const [itemCount, setItemCount] = useState(0);function addItem(product) {
const newItems = [...items, product];setItems(newItems);setTotalPrice(totalPrice + product.price); // 我自己维护总价setItemCount(itemCount + 1); // 我自己维护数量
}function removeItem(productId) {
const removed = items.find(i => i.id === productId);setItems(items.filter(i => i.id !== productId));setTotalPrice(totalPrice - removed.price);setItemCount(itemCount - 1);
}你能看出问题吗?
我在三个不同的地方维护着三份"真相"。只要有任何一个地方出错,整个购物车就乱套。
果然,后来有个 bug 出现了:用户点击"全选"之后,数量显示和实际列表对不上。我排查了半天,发现是某个角落的代码没有正确更新 itemCount。
那时候我才意识到——我根本不需要存这些值。
后来我改成:
const [items, setItems] = useState([]);// 这些都是计算出来的,不需要 state
const itemCount = items.length;
const totalPrice = items.reduce((sum, item) => sum + item.price, 0);function addItem(product) {setItems([...items, product]);
// itemCount 和 totalPrice 会自动"更新"
}function removeItem(productId) {setItems(items.filter(i => i.id !== productId));
// itemCount 和 totalPrice 会自动"更新"
}这样就再也不会出现同步问题了。因为根本没有多个"真相源"。
那次异步 setState 教给我的
我们有一个"点赞"功能。用户点点赞按钮,我们要:
立刻改变 UI(点赞按钮变亮)
同时发送请求给服务器
我最初是这样写的:
const [liked, setLiked] = useState(false);function handleLike() {setLiked(!liked);// 发送请求api.addLike(postId).catch(err => {// 如果失败,恢复状态setLiked(liked); // 这里有问题!});
}看出来了吗?我在 .catch() 里用的还是旧的 liked 值。
假设用户很快点了点赞,然后又点了取消。现在 liked 是 false。但如果第一个请求失败了,我的 catch 回调会把它改回 true(用的是当时的旧快照)。结果用户的操作就被反转了。
这是个经典的闭包陷阱。
正确做法是用函数式更新:
function handleLike() {setLiked(prev => !prev);api.addLike(postId).catch(() => {setLiked(prev => !prev); // 恢复前一个状态});
}这样不管发生了什么,我都是基于"最新的状态"来操作,不会出错。
计算型初始值,一个看不见的性能漏洞
我们有个很复杂的仪表板。首次加载时,需要做一堆初始化:处理大量数据、生成图表配置、诸如此类的。
我一开始这样做:
const [config, setConfig] = useState(generateComplexConfig(rawData));问题是:每次这个组件重新渲染,generateComplexConfig 都会被调用一遍。
虽然 React 最后不会真的用这个返回值(它只用第一次的),但 JavaScript 还是浪费了 CPU 去计算。在我们这个场景里,这个函数要跑两秒钟。组件每次重新渲染都要卡两秒,那就离谱了。
后来老大哥教我一个技巧:
const [config, setConfig] = useState(() => generateComplexConfig(rawData));只需要包装成一个函数,React 就只会在初始化时调用它。之后重新渲染时,它就不会再调用这个函数了。
这叫"懒初始化"。看起来简单,但对性能的影响能很显著。
真正的高手知道什么时候不用 useState
我刚工作时,什么东西都想放在 state 里。结果组件到处都是 useState,到处都是 re-render,到处都是 useEffect 来同步各种奇怪的东西。
后来我才学会问自己一个问题:这个东西真的需要是 state 吗?
比如说,我们有一个表单,用户不断地输入。每输入一个字符,我都在计算"还能输入多少字符"。
// ❌ 我最初想这样做
const [text, setText] = useState('');
const [remainingChars, setRemainingChars] = useState(100);function handleChange(e) {const newText = e.target.value;setText(newText);setRemainingChars(100 - newText.length);
}但这样的话,输入框每次改变都会触发两次 state 更新,两次 re-render。
实际上:
// ✅ 正确做法
const [text, setText] = useState('');// 直接算,不需要 state
const remainingChars = 100 - text.length;function handleChange(e) {setText(e.target.value);
}一行代码搞定,而且根本没有多余的 re-render。
还有,我之前想用 state 来存一个"用户操作了没"的标志,用来控制要不要显示某个提示:
// ❌ 不需要
const [hasOpened, setHasOpened] = useState(false);if (someCondition && !hasOpened) {showTip();setHasOpened(true);
}这会让组件重新渲染一遍(虽然 UI 可能不会变)。其实我只需要:
// ✅ 用 useRef
const hasOpenedRef = useRef(false);if (someCondition && !hasOpenedRef.current) {showTip();hasOpenedRef.current = true;
}ref 改变时不会触发 re-render,所以这里用它最合适。
写在最后
我和你说这些,不是为了装逼。而是想让你知道:
我也是从各种坑里爬出来的。
每一个"原来是这样"的时刻,都是在项目里被 bug 追着跑的时候学到的。
所以如果你现在写的代码不够完美,项目里还有各种 setState 的问题,这很正常。关键是要去理解——为什么会这样?为什么 React 要异步更新?为什么不能直接改对象?
一旦你真正理解了这些原理,不是背下来,而是在项目里用过几次,踩过几个坑,那么回头看你最开始的代码,你就会笑出声来。
然后你会开始写出更清晰、更少bug、更好维护的代码。
这就是进步。
