重新思考 React 状态:数据应真正存储在何处
由于减少了 useState 的使用,React 的错误也随之减少:将状态放到 URL、DOM 或派生值中,在使用状态管理之前先明确每部分状态的归属者。
来源: Stop Adding React State: It’s Creating Bugs You Don’t Need
在一家快速发展的SaaS公司里,一名销售代表每天早上都要花费大量时间浏览包含一万个账户的客户数据库。
经过仔细筛选后,他们找出了那些位于欧洲且在过去90天内未续订服务的企业客户。
最终得到了一份需要立即处理的30个账户的精确列表。
他们直接从浏览器中复制URL,通过Slack发送给区域主管,并附上说明:
“这是今天早上需要优先处理的账户列表。”
主管打开了该链接。
页面加载完成了。
然而显示的并非筛选后的列表,而是默认视图:全部一万个账户,未经筛选也未排序。
由于构建该页面的工程师使用 useState 将所有过滤条件、排序字段和分页参数都存储在本地组件状态中,那些精心设计的过滤机制全都消失了。
这种情况反映了在 React 代码库中屡见不鲜的一种模式。
我们之所以倾向于使用状态,只是因为 React 让其创建变得如此简单。
需要过滤值?
useState。
需要跟踪当前激活的标签页?
useState。
需要计数器?
useState。
需要保存表单字段?
useState。
需要跟踪 API 调用的不同阶段?
那又得使用三个 useState 调用。
不久之后,一个组件就要处理数十个原本根本不必存储在 React 状态中的值。
状态本身并非这里的敌人。每个复杂的 React 应用都需要状态。
真正的问题在于将状态放在不合适的位置。
每增加一个状态变量,应用就需要多做一件与其它部分保持同步的工作。
如果不加以控制,这往往会引发诸多问题,包括:
- 为保持重复值一致而耗费额外精力
- 数据在某个缓存中变得过时
- 屏幕上显示的数值不再与实际情况相符
- 实际上并未发生任何变化时仍触发渲染
- 两次更新意外冲突导致的时机问题
- 为保持一致性而变得愈发复杂的副作用
- 需要更长时间才能追溯到根源的错误
- 页面在重新加载后忘记所有内容
写出优秀的 React 代码并非在于掌握复杂的状态逻辑。
往往关键在于懂得何时根本就不该创建那种状态。
1. URL 是应用程序最好的状态管理器
想象一个典型的数据量较大的应用页面。
它可能需要追踪:
- 搜索内容
- 筛选条件
- 排序方式
- 分页信息
- 当前激活的标签页
- 已选类别
- 日期范围
通常的做法是将所有这些信息都存入组件状态中。
function ProductList() {
const [activeCategory, setActiveCategory] = useState('all');
const [currentPage, setCurrentPage] = useState(1);
const [searchQuery, setSearchQuery] = useState('');
// Fetch and filter data...
}
乍看之下,这似乎完全没有问题。
但当用户点击刷新后,
所有数值都会恢复为默认值。
或者他们将链接分享给同事。
那位同事看到的则是完全不同的页面。
或者他们点击浏览器的返回按钮。
之前设置的过滤条件可能不会恢复,因为 React 状态与浏览器导航历史之间没有内置关联。
useState 本身并无问题。
真正的问题在于URL 本就是用于表示导航状态的机制,但却被完全忽略了。
不必这样写:
const [currentPage, setCurrentPage] = useState(1);
const [searchQuery, setSearchQuery] = useState('');
const [category, setCategory] = useState('all');
可以直接在 URL 中编码相同的信息。
类似这样:
/products?category=enterprise&page=3&query=react
使用 Next.js、React Router 或 TanStack Router 等路由库,可以直接从 URL 中读取这些参数:
import { useSearchParams } from 'next/navigation';
function ProductList() {
const searchParams = useSearchParams(); const category = searchParams.get('category') || 'all';
const page = Number(searchParams.get('page')) || 1;
const query = searchParams.get('query') || ''; // Fetch data using the URL state...
}
这一小小的改变会极大影响该状态的生命周期。
刷新页面?
筛选条件保持不变。
分享链接?
任何打开该链接的人都会看到完全相同的界面。
保存为书签?
稍后再次打开时会恢复到相同状态。
点击返回?
浏览器会正确切换到之前的状态。
这是个重要的架构优势。
URL状态带来的远不止持久性
仅能抵御页面刷新只是其好处之一。
更大的优势在于,URL能够成为可分享的、能反映应用当前状态的便携式快照。
以客户支持控制台为例:
/support/tickets?status=open&priority=high&team=billing
一名经理可以立即将该链接发送给另一名经理。
以销售为导向的控制台可能如下所示:
/accounts?region=europe&plan=enterprise&renewal=90
分析工具则可能会使用类似的形式:
/analytics?range=30d&metric=revenue&groupBy=region
这些都不是随意生成的查询字符串。
每一个都是指向应用中特定、可重建状态的深度链接。
这正是URL存在的意义所在。
何时使用URL状态
URL参数非常适合用于以下场景:
- 用户输入到搜索框中的内容
- 当前选定的过滤条件
- 数据排序的列和顺序
- 当前显示的结果页码
- 当前选中的标签页
- 所选择的日期范围
- 当前打开的已保存视图
- 生成报告的相关设置
- 控制面板的自定义配置
- 用户希望传递给他人的任何界面状态
不过,并非所有内容都适合放在URL中。
密码显然不应出现在URL里。
短暂的模态动画状态也不应如此。
一般来说,大型且深度嵌套的对象同样不应如此。
核心问题很简单:
问问自己,用户是否合理地期望该视图可以分享、添加书签或恢复——如果是这样,那么URL很可能是存储该状态的最佳位置。
2. 停止存储本可直接计算出的值
简化React状态时一个快速有效的办法,就是停止持久化那些实际上是从其他值派生而来的数据。
看这个例子:
const [items, setItems] = useState([]);
const [itemCount, setItemCount] = useState(0);
在组件的其他地方还有:
setItems(newItems);
setItemCount(newItems.length);
现在你有了两个独立的状态变量,却描述着相同的底层事实。
如果items被更新了,而itemCount没有随之变化,界面显示的内容就会出错。
你制造出了本不必存在的同步难题。
更简洁的写法如下:
const [items, setItems] = useState([]);
const itemCount = items.length;
现在有了唯一的真实数据源。
每次组件渲染时都会计算该数值。
过滤集合时也适用同样的逻辑。
不必这样写:
const [items, setItems] = useState([]);
const [activeItems, setActiveItems] = useState([]);
通常可以直接这样写:
const activeItems = items.filter(item => item.active);
或者:
const totalPrice = items.reduce(
(total, item) => total + item.price,
0
);
又或者:
const hasErrors = fields.some(field => field.error);
再或者:
const completedCount = todos.filter(todo => todo.completed).length;
值得思考的问题不是:
“从技术上讲,我能把这个存储在状态中吗?”
当然可以。
真正应该问的是:
“这个值是否有独立的生命周期?”
如果没有,它就不适合成为状态。
3. 停止将每个表单字段都与状态绑定
另一个应当改掉的习惯是将每个表单字段视为独立的状态变量。
用这种方式构建的简单表单可能如下所示:
function ContactForm() {
const [firstName, setFirstName] = useState('');
const [lastName, setLastName] = useState('');
const [email, setEmail] = useState('');
const [message, setMessage] = useState('');
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault(); submitData({
firstName,
lastName,
email,
message
});
}; return (
<form onSubmit={handleSubmit}>
<input
value={firstName}
onChange={e => setFirstName(e.target.value)}
/> <input
value={lastName}
onChange={e => setLastName(e.target.value)}
/> <input
value={email}
onChange={e => setEmail(e.target.value)}
/> <textarea
value={message}
onChange={e => setMessage(e.target.value)}
/> <button type="submit">Send</button>
</form>
);
}
从技术层面来看,这些做法并没有错误。
只有当应用程序确实需要在用户输入时做出响应时,才需要使用受控输入。以下是几种需要这样做的情况:
- 在用户输入时检查字段的有效性
- 提供自动补全式的建议
- 实时重新格式化文本
- 统计剩余字符数
- 根据输入显示或隐藏其他字段
- 评估密码的强度
- 将一个字段的行为与另一个字段的值关联起来
但通常情况下,直到用户点击提交之前,应用程序都没有必要关心他们输入了什么。
在那种情况下,React无需在用户输入时跟踪每一个字符。
浏览器已经在负责这项工作了。
让浏览器来处理
原生HTML表单数十年来一直在管理输入值。
你可以利用FormData来实现这一内置功能:
function ContactForm() {
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
const formData = new FormData(e.currentTarget); const payload = {
firstName: formData.get('firstName') as string,
lastName: formData.get('lastName') as string,
email: formData.get('email') as string,
message: formData.get('message') as string,
}; submitData(payload);
}; return (
<form onSubmit={handleSubmit}>
<input name="firstName" required />
<input name="lastName" required />
<input name="email" type="email" required />
<textarea name="message" required /> <button type="submit">Send</button>
</form>
);
}
采用这种模式后,React不再需要为每个字段设置单独的状态变量。
浏览器会保存用户输入的内容。
表单提交后,React只需读取这些值即可。
对于大型表单而言,这种方式尤为有用。
想象一下包含30个字段的企业入职流程。
你真的会为了存储用户输入的内容而创建30个独立的状态变量吗?
大概不会。
受控输入与不受控输入可以共存
这一切并不意味着受控组件是错误的做法。
它们并非如此。
有很多情况下,控制输入数据是完全正确的做法。
关键在于打破React默认必须掌控每个字段的值这一假设。
如果应用在提交之前不需要这些数据,让原生表单来存储通常就足够了。
做出这种区分可以大大简化复杂的表单结构。
4. 为什么布尔标志会出问题
另一个常见问题在现实世界的代码库中屡见不鲜。
以典型的API调用为例。
常见的处理方式是使用三个独立的布尔值来跟踪状态:
const [isLoading, setIsLoading] = useState(false);
const [isSuccess, setIsSuccess] = useState(false);
const [isError, setIsError] = useState(false);
最初,这三个值均为false:
loading = false
success = false
error = false
一旦开始发起请求:
loading = true
当请求成功完成时:
loading = false
success = true
乍看之下,这种做法似乎合理。
但假设错误处理分支未能重置 isLoading,会怎样呢?
可能会出现以下情况:
loading = true
error = true
或者一个不完善的重试机制可能导致:
success = true
error = true
此时,该组件的状态在逻辑上就变得不合理了。
它应该显示成功消息吗?
还是应该显示错误状态?
又或者仍需展示加载指示器?
从技术上讲,这三个布尔值都可能同时支持“是”的答案。
正是这种模糊性构成了错误。
改用有限状态机来处理
一种更清晰的方法是:
type RequestStatus =
| 'idle'
| 'loading'
| 'success'
| 'error';
const [status, setStatus] =
useState<RequestStatus>('idle');
现在每个有效状态都是明确且互斥的。
请求只能处于以下状态之一:
idle
loading
success
error
这样就无法意外地同时表示“加载中”和“已成功”的状态了。
渲染过程的理解也会变得简单得多:
if (status === 'loading') {
return <Spinner />;
}
if (status === 'error') {
return <ErrorMessage />;
}if (status === 'success') {
return <SuccessBanner />;
}return <DefaultForm />;
这并非 TypeScript 的附加功能而已。
它体现了一个更广泛的设计原则:
当两个状态值永远无法独立为真时,就不应将其建模为两个独立的变量。
5. 避免使用 Effects 来保持状态同步
这也是额外状态变量会生成多余且本可避免的代码的一个例子。
以这样的结构为例:
const [firstName, setFirstName] = useState('');
const [lastName, setLastName] = useState('');
const [fullName, setFullName] = useState('');
与之搭配的是:
useEffect(() => {
setFullName(`${firstName} ${lastName}`);
}, [firstName, lastName]);
你在这里构建的其实是一种同步机制,而该值从一开始就不需要独立的存储空间。
更简单的解决方案是:
const fullName = `${firstName} ${lastName}`;
虽然改动看起来微不足道,但对架构的影响却相当显著。
原始版本需要:
- 三个独立的变量
修改后的版本只需要:
- 两个真实数值
- 由它们派生出的一个数值
- 无需任何效应
- 无需维护同步逻辑
审查 React 组件时的一个好习惯是问:
“为什么这个数值需要存在于渲染计算之外?”
如果诚实的答案是“为了与其他内容保持同步”,那通常意味着这个数值根本就不应该作为状态。
6. 添加状态前应问的三个问题
在编写以下代码之前:
const [value, setValue] = useState(...);
先进行三项检查。
问题 1:能否在渲染过程中计算出这个值?
如果生成该值所需的输入已经存在,就没有必要存储重复数据。
不必:
const [itemCount, setItemCount] = useState(0);
写入:
const itemCount = items.length;
不必:
const [hasErrors, setHasErrors] = useState(false);
写入:
const hasErrors = fields.some(field => field.error);
对于那些可以即时计算得到的信息,应避免使用状态存储。
问题2:此数据在页面重新加载后是否应保留?
如果需要保留,需考虑该URL是否适合作为其存储载体。
常见的适用场景包括:
- 搜索词
- 分页状态
- 当前筛选条件
- 排序顺序
- 已选标签页
- 日期范围
请自问:如果有人分享了此页面的链接,接收者是否应该看到完全相同的界面?
如果是这样,通常URL才是存储该数据的更好选择。
问题3:浏览器能否在表单提交前一直保存此数据?
如果某个字段的值仅在提交时重要,那么使用不受控制的输入方式可能就足够了。
不必:
const [email, setEmail] = useState('');
你可能只需要:
<input name="email" type="email" />
稍后再用以下方式读取它:
const formData = new FormData(e.currentTarget);
让浏览器处理它本来就该处理的事情吧。
7. 真正适合放入 React 状态中的内容
以上内容都并非主张完全取消组件状态。
试图彻底去除组件状态属于过度纠正。
有许多值确实适合存储在状态中。
例如:
const [isOpen, setIsOpen] = useState(false);
临时的下拉菜单或模态窗口完全不必反映在 URL 中。
交互式状态
const [activeStep, setActiveStep] = useState(1);
当某个值驱动的交互完全发生在同一个组件内部时,将其保留在本地状态中通常是正确的选择。
独立变化的值
如果某个事物确实拥有自己的生命周期,且无法通过现有数据来计算出该周期,那么状态就是存储它的合适地方。
乐观 UI
有时需要在服务器确认某件事发生之前就在屏幕上显示它。
这也是状态的一个合理用途。
重点并非遵循以下规则:
"无论如何都要少用
useState。"
真正的重点应该是:
"确保每个状态都有合适的所有者。"
有时合适的所有者是 React。
有时是 URL。
有时是 DOM。
有时是服务器。
有时则是专门用于数据获取或状态管理的层。
正是这一所有权决策真正决定了应用程序的架构。
8. 关注状态的拥有者,而不仅仅是状态的数量
一个构建良好的 React 应用程序并非由少量的状态变量来定义。
真正重要的是为每个状态分配一个明确且正确的拥有者。
以用于跟踪数据的控制面板为例:
Search query
Filters
Pagination
Sort order
Form values
Server data
Loading status
Selected row
Modal visibility
Derived totals
人们很容易不加思考地将所有这些内容都放入 useState 中。
但这些值的职责并不相同,因此不应放在同一个地方。
一个更实用的分类方式如下:
URL
- 搜索功能
- 筛选条件
- 分页功能
- 排序规则
- 可分享的标签页
DOM
- 仅在表单提交后才重要的表单值
服务器/数据层
- 客户记录
- API响应
- 缓存的服务器数据
衍生值
- 计数
- 总计
- 过滤后的列表
- 验证摘要
React状态
- 模态框的显示状态
- 临时选择项
- 动画状态
- 短暂存在的交互状态
以这种方式分配状态后,组件需要保持同步的内容就会大大减少。
正是这种减少带来了真正的益处。
9. 重复的状态是未来错误的根源
应用程序中最危险的状态未必是最复杂的状态。
往往正是同时存在于多个地方的状态。
想象一下你有:
const [products, setProducts] = useState([]);
const [filteredProducts, setFilteredProducts] = useState([]);
现在你需要确保 filteredProducts 始终与 products 中的内容保持一致。
再添加一个过滤器后就会出现:
const [filteredProducts, setFilteredProducts] = useState([]);
const [searchResults, setSearchResults] = useState([]);
这时就需要手动维护另一层关联关系的一致性。
迟早会有人更新底层数据,却忘了同步依赖它的某个派生变量。
接口就会逐渐与实际数据脱节。
最终用户会反馈类似以下问题:
"显示的商品数量是42,但屏幕上只展示了39个。"
这种不一致通常并非过滤逻辑本身的缺陷。
而是应用程序对同一信息存在多个数据来源的表现。
如果改为这样派生该值:
const filteredProducts = products.filter(...);
你能在问题发生之前就彻底消除整类错误。
这就是为什么减少状态并不仅仅关乎渲染性能的原因。
其核心在于降低出现不同步情况的可能性。
10. 状态的成本远不止于重新渲染
当人们讨论状态时,话题通常集中在性能上:“这会触发另一次渲染吗?”
这确实是个值得关注的问题,但它并非最昂贵的部分。
真正的成本在于复杂性。
每增加一个状态变量,团队就需要多记住一项内容。
随后不可避免地还会增加一个设置器调用。
setSomething(...)
接着,可能需要用 useEffect 来响应这一变化。
useEffect(...)
然后还需要一个依赖数组来列出它所监控的数值。
[firstValue, secondValue]
然后需要某种重置逻辑来使其恢复到初始值。
setSomething(initialValue)
接着会出现一种边界情况,即该状态必须与导航变化保持一致。
再之后就会有人提交错误报告,因为某些数据变更后状态并未被清除。
渐渐地,代码库就会变成一张由同步逻辑构成的复杂网络。
相比那些数据来自少数明确定义的来源且流动清晰的组件,这种结构要难理解得多。
React 的真正技巧不在于管理更多状态
而在于明白每部分状态应该放在何处。
经验较少的开发者在查看用户界面时可能会问:
"这需要什么状态?"
而具备更好架构思维的人则会提出不同的问题:
“这些值中究竟哪些真的需要被保存为状态?”
而更尖锐的问法是:
“每项状态的正确所有者是谁?”
以这种方式转变思维方式,能彻底改变你构建 React 应用程序的方式。
URL 可以负责导航状态。
浏览器可以负责表单状态。
服务器可以负责由服务器管理的数据。
派生值只需保持其派生状态,无需重复存储。
TypeScript 的联合类型可以用来表示互斥的状态。
而 React 本身则可以专注于那些真正属于 UI 层的短暂、交互式状态。
这样的分工能让架构更加简洁。
总结
拥有更多的状态本身并非一种成就。
状态变量的数量越多,并不会让应用程序的功能更强大。
通常这只意味着更多的同步工作、更多需要追踪的依赖关系,以及数据出现不一致的可能性增大。
在再次使用 useState 之前,先思考以下问题:
- 这个值可以在渲染时计算出来吗? 如果可以,那就直接推导出来,而非单独存储。
- 它需要在页面刷新后仍然存在,或能通过链接共享吗? 如果需要,就将其放入 URL 的查询参数中。
- 是否需要在每次按键时都跟踪这个表单字段? 如果不需要,使用未加控制的输入方式搭配
FormData可能就足够了。
目标从来不是减少useState调用的次数。
而是确保尽可能少的值来决定应用程序的真值状态。
因为每一个不必要的状态都会增加应用需要同步的内容。
而每避免编写一次同步代码,就意味着少了一个需要修复的漏洞。