前端状态分层规范
版本:1.0 | 更新日期:2026-10-07
技术栈:React 19 · TanStack Query v5 · jotai · Tailwind CSS 4
一、核心原则
前端状态按数据来源与共享范围分为三层,每层有且仅有一个正确工具:
| 层级 | 工具 | 管什么 | 不管什么 |
|---|---|---|---|
| 服务端状态 | TanStack Query(useQuery / useMutation) | 来自后端 API 的数据:列表、详情、搜索结果、分析结果 | 纯 UI 交互(展开/折叠、选中行) |
| 全局客户端状态 | jotai atoms | 跨页面/跨组件共享的纯 UI 状态:主题、侧边栏折叠、通知、认证 | 任何可以从 API 获取的数据 |
| 组件本地状态 | useState / useReducer | 单个组件内的 UI 状态:表单输入、弹窗开关、临时选中 | 需要跨组件共享的状态、API 数据 |
判断标准(按顺序问):
这个数据是从后端 API 来的吗?
├─ 是 → TanStack Query(useQuery 读 / useMutation 写)
└─ 否 ↓
需要跨多个不相关组件共享吗?
├─ 是 → jotai atom
└─ 否 → useState / useReducer
二、TanStack Query 约定
2.1 何时使用
- 所有
GET请求的数据读取 →useQuery - 所有
POST/PUT/DELETE等写操作 →useMutation - 数据有「新鲜度」概念(服务端可能被其他人修改)→
useQuery - 需要缓存、去重、自动重试、后台刷新 →
useQuery
2.2 Query Key 规范
所有 query key 统一在 src/lib/queryKeys.ts 的 qk 工厂对象中注册,禁止在组件内手写 key 数组。
// ✅ 正确
import { qk } from "@/lib/queryKeys";
useQuery({ queryKey: qk.analysis.detail(stockCode), ... });
// ❌ 错误
useQuery({ queryKey: ["analysis", stockCode], ... });
Key 结构遵循「域 → 资源 → 参数」层级,已有的 57 个命名空间覆盖了 market / stock / analysis / strategies / tasks 等全部 API 域。
2.3 useMutation 数据同步策略
Mutation 成功后更新缓存有两种方式,按场景选择:
| 策略 | 方法 | 适用场景 | 注意事项 |
|---|---|---|---|
| 即时写入 | queryClient.setQueryData(key, newData) | 写操作的返回值就是最新数据(如 AI 分析结果) | 不触发 refetch,无 loading 闪烁 |
| 失效重取 | queryClient.invalidateQueries({ queryKey }) | 写操作后需要重新 GET 才能拿到最新数据(如修改配置) | 会触发 isLoading,注意 UI 不要闪全页 spinner |
// 即时写入 — 分析结果直接入缓存
const mutation = useMutation({
mutationFn: (code: string) => analyzeStock(code),
onSuccess: (result) => {
queryClient.setQueryData(qk.analysis.detail(code), result);
},
});
// 失效重取 — 配置修改后重拉
const mutation = useMutation({
mutationFn: updateConfig,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: qk.settings.config() });
},
});
2.4 错误分层
useQuery 的错误(query.error)和 useMutation 的错误(mutation.error)语义不同,不要混用同一个 state:
- 加载错误(
query.error):GET 失败,展示重试按钮 - 操作错误(
mutation.error或自定义runError):POST 失败,展示操作失败提示
两者同时存在时,操作错误优先(用户刚触发的操作比历史加载状态更重要)。
2.5 交易时段轮询
盘中实时数据(行情/盘口/涨跌分布)使用 useTradingQuery(封装了 useQuery + 交易时段判断 + 轮询间隔),不要自己写 refetchInterval + isTradingHours 判断。
三、jotai 约定
3.1 何时使用
- 状态与 API 数据无关,纯客户端 UI
- 需要跨不相关组件共享(不通过 props 传递)
- 当前项目 13 个 atom 全部符合此条件:主题、侧边栏、认证、通知、配额弹窗
3.2 何时不用
- 数据来自后端 API → 用 TanStack Query
- 只在单个组件内使用 → 用
useState - 父子组件共享 → 用 props 或
useState提升到父组件
3.3 Atom 定义位置
所有 atom 定义在 src/store/ 目录,按领域分文件:
| 文件 | 内容 |
|---|---|
atoms.ts | 主题、侧边栏、聊天、活跃股票等 UI 状态 |
authAtoms.ts | 认证用户、登录状态 |
notificationAtoms.ts | 通知列表、未读计数、操作函数 |
quotaAtoms.ts | AI 配额弹窗 |
新增 atom 须在对应文件中定义,不要在组件文件内定义 atom(会导致模块耦合和循环依赖)。
四、useState 约定
4.1 正确使用场景
- 表单输入值(
<input>的value) - 弹窗/抽屉开关(
dialogOpen) - 展开/折叠状态
- 临时选中项(表格当前行)
- 组件内的派生计算中间值
useMutation的附加状态(如runError、partial)
4.2 禁止使用场景
// ❌ 禁止:useState 存 API 数据
const [stocks, setStocks] = useState([]);
useEffect(() => {
fetch("/api/v1/stocks").then(r => r.json()).then(setStocks);
}, []);
// ✅ 正确:useQuery 管理 API 数据
const { data: stocks } = useQuery({
queryKey: qk.stocks.list(),
queryFn: () => request.get("/stocks"),
});
4.3 迁移优先级
项目中仍有约 34 个文件使用 useState + useEffect + request 模式获取服务端数据。迁移优先级:
- 高频页面(Home / SmartPick / FactorPick / HotTrending)— 用户感知最强
- 多组件共享同一数据 — 迁移后可享受 Query 缓存去重
- 有轮询需求 — 迁移后可用
refetchInterval替代手写setInterval - 低频管理页面 — 收益较低,按需迁移
五、反模式清单
| 反模式 | 问题 | 正确做法 |
|---|---|---|
useState + useEffect 取 API 数据 | 无缓存、无去重、无自动重试、切换组件重新加载 | useQuery |
useQuery 的 data 复制到 useState | 两份状态不同步、失去 Query 缓存意义 | 直接用 query.data |
| jotai atom 存 API 数据 | 无缓存策略、无失效机制、手动管理同步 | useQuery |
useMutation 成功后 invalidateQueries 导致全页闪烁 | 用户看到 loading spinner 替代了已有数据 | setQueryData 即时写入 |
| 组件内定义 jotai atom | 模块耦合、循环依赖风险 | 定义在 src/store/ |
| 手写 query key 数组 | 不同组件 key 不一致导致缓存失效 | 统一用 qk 工厂 |
useTradingQuery 外再写 isTradingHours 判断 | 重复逻辑、可能不一致 | 直接用 useTradingQuery |
单个 error state 混合加载错误和操作错误 | 用户无法区分「加载失败」和「操作失败」 | 分开追踪,操作错误优先 |
六、迁移检查清单
将 useState + useEffect 模式迁移到 useQuery 时,逐项确认:
- 在
qk中有对应的 query key(没有则新增) -
queryFn返回的数据类型与useState的泛型一致 - 设置了合理的
staleTime(静态数据 5min+,实时数据 0) - 设置了合理的
gcTime(默认 5min,高频页面可延长到 30min) - 移除了
useEffect中的手动 fetch 逻辑 - 错误处理从
catch改为query.error或error派生 - Loading 状态从自定义
loading改为query.isLoading/query.isFetching - 如有写操作,使用
useMutation并在onSuccess中同步缓存
七、当前迁移状态
| 类别 | 已完成 | 待迁移 |
|---|---|---|
自定义 hooks(src/hooks/) | 11 个 useQuery + 1 个 useMutation | — |
jotai atoms(src/store/) | 13 个(全部为客户端状态) | — |
页面/组件中的服务端 useState | 少量 | ~34 个文件 |