React Data Flow
状态管理与数据请求是数据流的一体两面:请求产出的就是状态(data/loading/error),状态变化触发请求,Hook 编排串联两者。本 skill 提供体系化规范,覆盖选型、模式、编排、防竞态、三态、错误反馈。
优先级仲裁:项目内存在更具体的规范(
.claude/rules/、conventions、团队规范)时,以项目规范为准;本 skill 仅作通用兜底。
覆盖:useState/useReducer/Context/Zustand/URL 状态选型、useRequest/SWR/React Query 代表实现、查询/变更模式、三态处理、防竞态与取消、Hook 拆分(useXxxList + useXxxMutations)、错误反馈、并发请求。
不覆盖(独立主题):表单状态管理、流式对话(SSE/WebSocket)、组件 API 设计、性能优化。
When to Apply
- 编写或审查 React 状态管理代码(选 useState/useReducer/Context/状态库/URL 之一)
- 实现数据请求逻辑(fetch/axios/useRequest/SWR/React Query)
- 设计 Hook(useXxxList、useXxxMutations、自定义数据 hook)
- 处理 loading/error/empty 三态、防竞态、取消、并发
- 重构数据流(状态提升、Hook 拆分、请求模式改造)
- 实现 CRUD 列表页或详情页的数据流编排
§1 状态管理
1.1 useState 基础
函数式更新避免 stale closure。当 setState 依赖当前 state 时,用函数式形式——回调引用稳定(不需把 state 放进依赖数组),且永远基于最新 state,不会读到过期闭包。
// 依赖 items,回调每次 items 变都重建,且漏掉依赖时会读到旧值
const addItems = useCallback((newItems: Item[]) => {
setItems([...items, ...newItems]);
}, [items]);
// 函数式更新:回调稳定,永远基于最新 state
const addItems = useCallback((newItems: Item[]) => {
setItems(curr => [...curr, ...newItems]);
}, []);
惰性初始化。初始值昂贵计算时传函数,否则每次 render 都会执行计算(即使结果被丢弃)。
const [data, setData] = useState(expensiveCompute()); // 每次 render 都跑
const [data, setData] = useState(() => expensiveCompute()); // 仅首次 render
派生状态不用 effect。能从 props/state 计算的值,render 期直接派生。用 effect 同步会引入额外渲染 + 状态漂移(派生值短暂与源不同步)。
const [fullName, setFullName] = useState('');
useEffect(() => { setFullName(`${firstName} ${lastName}`); }, [firstName, lastName]);
const fullName = `${firstName} ${lastName}`; // 直接派生
1.2 useReducer 选型
useState 能搞定就不用 useReducer。当状态机复杂(多字段联动、next state 依赖 prev 多步、明确的状态转移图)时,useReducer 把逻辑从组件中抽出,更易测试。
type FormState = { status: 'idle' | 'submitting' | 'error'; data: Form; error: Error | null };
type FormAction =
| { type: 'submit' }
| { type: 'success'; data: Form }
| { type: 'fail'; error: Error };
function formReducer(state: FormState, action: FormAction): FormState {
switch (action.type) {
case 'submit': return { ...state, status: 'submitting', error: null };
case 'success': return { ...state, status: 'idle', data: action.data };
case 'fail': return { ...state, status: 'error', error: action.error };
}
}
1.3 Context 设计与陷阱
适用:依赖注入(theme/auth/i18n)、低频更新配置、跨层级共享只读数据。
不适用:高频更新状态、全局业务状态。Context value 变化会触发所有消费者重渲染,无法按字段订阅——这是 Context 的设计本质,不是性能 bug。
const ThemeContext = createContext({ theme: 'light', toggleTheme: () => {} }); // 低频,OK
const useCartStore = create<CartState>(() => ({})); // 高频,用状态库
value 必须用 useMemo 稳定,否则每次 Provider 重渲染都触发全部消费者重渲染。
const value = useMemo(() => ({ theme, toggleTheme }), [theme, toggleTheme]);
<ThemeContext.Provider value={value}>
拆分 Context:高频字段与低频字段拆开,避免高频变化拖累低频消费者。
1.4 状态库通用规范(以 Zustand 为代表)
按字段订阅。整体订阅会让任何字段变化都触发重渲染,违背状态库分片订阅的优势。
const user = useAuthStore(s => s.user);
// 组合多字段用 useShallow(避免每次 render 新对象引用触发重渲染)
const { user, isAuthenticated } = useAuthStore(
useShallow(s => ({ user: s.user, isAuthenticated: s.isAuthenticated }))
);
const authStore = useAuthStore(); // 整体订阅,任何字段变化都重渲染
persist + partialize:仅持久化指定字段。瞬态字段(loading/error)不进 localStorage,否则下次启动会看到陈旧的 loading 态。
persist(
(set) => ({ sidebarCollapsed: false, loading: false, toggleSidebar: () => set(...) }),
{
name: 'app-store',
partialize: (s) => ({ sidebarCollapsed: s.sidebarCollapsed }), // 只持久化非瞬态
}
)
immer 中间件:嵌套深更新用 immer,避免手动展开 {...prev, child: {...prev.child, field: value}} 这种易错写法。
immer((set) => ({
project: { id: '', config: { mode: 'simple', params: {} } },
updateParam: (key, value) => set((s) => {
s.project.config.params[key] = value; // immer 内可直接修改
}),
}))
异步 action 显式管理 loading/error。不处理会导致 UI 永远卡在 loading 或静默失败。
loadProjects: async () => {
set({ loading: true, error: null });
try {
const res = await projectAPI.list();
set({ projects: res.list, loading: false });
} catch (e) {
set({ loading: false, error: e as Error });
}
}
防竞态(模块级版本号)。搜索、快速切换场景,丢弃过期响应,否则旧请求会覆盖新结果。
let searchVersion = 0;
searchProjects: async (keyword: string) => {
const reqId = ++searchVersion;
set({ searching: true });
try {
const res = await projectAPI.search({ keyword });
if (reqId !== searchVersion) return; // 丢弃过期响应
set({ searchResults: res.list, searching: false });
} catch (e) {
if (reqId !== searchVersion) return;
set({ searching: false });
}
}
1.5 URL 状态
useSearchParams / useUrlState 模式:可分享状态(筛选条件、分页、当前 tab)写入 URL,便于书签 + 浏览器后退。
const [filter, setFilter] = useUrlState<{ keyword?: string; status?: string }>({});
const handleSearch = (v: string) => setFilter({ keyword: v });
空值不写入 URL(避免 ?keyword=&status= 噪音)。仅"用户可分享/可恢复"的状态进 URL,表单临时值、弹窗 open/close 不进。
1.6 状态边界
| 状态类型 | 落位 | 示例 |
|---|---|---|
| 全局共享(跨页面) | 状态库 | 用户信息、主题、当前项目 |
| 页面局部 | 页面 hooks + useState | 列表筛选、当前编辑行 |
| 组件内部 | useState | 弹窗 open、hover 态 |
| 表单字段 | Form 状态管理(不进状态库) | 输入框值、校验错误 |
| URL 可分享 | useSearchParams | 筛选条件、分页 |
把页面局部状态放进全局状态库会污染全局命名空间且难以清理。
§2 数据请求
2.1 请求库选型
| 库 | 自动执行 | 缓存 | 去重 | 竞态 | 变更 | 适用 |
|---|---|---|---|---|---|---|
| fetch/axios | 否 | 否 | 否 | 手动 | 手动 | 简单场景、需完全控制 |
| useRequest (ahooks) | 是 | 否 | 否 | 内置 | manual | 通用 CRUD、列表+变更 |
| SWR | 是 | 强 | 是 | 内置 | useSWRMutation | 缓存优先、不可变数据 |
| React Query | 是 | 强 | 是 | 内置 | useMutation | 复杂缓存策略、服务端状态 |
useRequest 与 SWR/React Query 的本质区别:useRequest 的"内置竞态"仅指单实例内自动取消旧请求,没有跨组件缓存与请求去重——同 key 的两次调用各自发请求;SWR/React Query 则按 key 共享缓存、自动合并并发请求。需要跨组件共享服务端状态时选 SWR/React Query,仅做页面级 CRUD 时 useRequest 足够。
项目已用某库就统一用某库,不混用。无偏好时 useRequest 最易上手,React Query 功能最全。
2.2 查询 vs 变更模式
查询:auto + refreshDeps + ready 守护
const [filter, setFilter] = useState(initialFilters);
const [pagination, setPagination] = useState({ pageNum: 1, pageSize: 10 });
const requestParams = useMemo(
() => ({ ...filter, ...pagination }),
[filter, pagination]
);
const { data, loading, error, refresh } = useRequest(
() => api.search(requestParams),
{
refreshDeps: [requestParams], // 参数变化自动重新请求
ready: !!projectId, // 前置条件不满足不请求
}
);
refreshDeps 用对象引用会无限请求(每次 render 新对象),用 useMemo 稳定。
变更:manual + 独立 loading + try/finally + refresh 联动
const [creating, setCreating] = useState(false);
const handleCreate = useCallback(
async (data: CreatePayload): Promise<Record> => {
setCreating(true);
try {
const record = await api.create(data);
refresh(); // 成功后刷新列表,保持 UI 数据一致
return record;
} finally {
setCreating(false); // finally 复位,异常时也复位
}
},
[refresh]
);
变更 loading 必须独立 useState,不混入 useRequest 的 loading——查询与变更 loading 共享会导致"创建中"时列表也显示 loading。
详情:manual + run(id) 触发
const { data: detail, loading: detailLoading, run: fetchDetail } = useRequest(
(id: string) => api.get(id),
{ manual: true }
);
const handleRowClick = (row: Item) => fetchDetail(row.id);
2.3 三态处理
每个异步页面必须处理 loading / error / empty / data 四态——缺失任何一态都会让用户在边界场景困惑(卡白屏、报错无提示、空数据无引导)。
| 状态 | 实现 |
|---|---|
| loading | loading prop 或 <Skeleton> |
| error | 错误态组件 + 重试按钮 |
| empty | 空态组件 或 Table locale.emptyText |
| data | 正常展示 |
列表页必备:加载态 / 空态 / 错误态 / 分页 / 查询 / 重置查询。 表单提交必备:提交 loading / 校验错误展示 / 失败反馈 / 成功反馈 / 防重复提交。
2.4 防竞态与取消
参数快速变化时(搜索、切换 Tab、远程 Select),必须避免旧请求覆盖新状态。
- 请求库内置竞态控制:useRequest/SWR 默认处理,旧请求不会覆盖新数据
- 版本号防竞态:状态库异步 action 场景(见 §1.4),模块级变量 + 过期响应丢弃
- AbortController:原生 fetch 场景主动取消
const controller = new AbortController();
fetch('/api/search', { signal: controller.signal });
controller.abort();
- debounce:搜索输入用
debounceWait: 300或useDebounceFn,避免每次击键请求
手写 AbortController 替代请求库通常是无谓的复杂度(除非有明确性能瓶颈)。useEffect + setTimeout 模拟 debounce 是反模式——用库内置。
2.5 缓存与去重
SWR/React Query 自动去重 + 缓存:同 key 多组件实例共享一个请求。
function UserAvatar({ userId }: { userId: string }) {
const { data: user } = useSWR(`/api/user/${userId}`, fetcher); // 自动去重
}
useRequest 无此能力,跨组件共享同一请求结果需自行实现(模块级 Map + cacheKey)。强实时性数据(监控、库存、价格)不用缓存,每次都需最新。
§3 数据流编排
3.1 Hook 拆分模式
风格 B 拆分式(默认):标准 CRUD 页面默认采用。list hook 只管查询,mutations hook 只管变更,通过 onSuccess 注入 list refresh。这样拆是为了职责单一——list hook 可独立测试,mutations 可独立替换,刷新逻辑由调用方装配。
function useXxxList(): UseXxxListResult {
// data / loading / error / refresh / filterProps / paginationProps
}
function useXxxMutations(opts: { onSuccess?: () => void }): UseXxxMutationsResult {
// createDialogProps / editDialogProps / handleDelete / handleToggleStatus
}
function XxxPage() {
const listHook = useXxxList();
const mutationsHook = useXxxMutations({ onSuccess: listHook.refresh });
return (
<>
<FilterBar {...listHook.filterProps} />
<Table dataSource={listHook.data} loading={listHook.loading} />
<CreateDialog {...mutationsHook.createDialogProps} />
</>
);
}
mutations hook 内部直接调 list hook 的 refresh 会造成循环依赖 + 难以独立测试——通过 onSuccess 注入是显式的、可替换的。
风格 A 合并式:适用简单页面(1 查询 + ≤2 mutation + ≤1 dialog + 字段 ≤15)。返回值平铺,仍遵守"变更 loading 独立 + try/finally + refresh 联动"。
风格 C 依赖注入式:适用 ≥3 个高度相似实体共享流程(如行业/领域/场景),需 ADR 记录为何抽象。
function useSimpleCrudList<TItem, TParams>(
opts: { fetchList: (p: TParams) => Promise<PageResponse<TItem>>; buildParams: (...) => TParams }
): UseSimpleCrudListResult<TItem> { /* ... */ }
const listHook = useSimpleCrudList({
fetchList: industryAPI.list,
buildParams: (page, search, status) => ({ ...page, name: search, status }),
});
返回值类型必须显式定义 interface——否则调用方拿到 any 会破坏类型安全。
3.2 错误处理与用户反馈
静默吞错(.catch(() => {}) 无提示)是 bug 源头——用户不知道操作失败,开发者排查也无线索。
try {
await api.create(data);
} catch (e) {
// 啥也不做 —— 用户以为成功,数据其实没保存
}
try {
await api.create(data);
} catch (e) {
console.error('Create failed:', e);
notifyError('创建失败,请重试'); // 用户可感知
throw e; // 让调用方知道
}
- 错误必须用户可感知:toast / notification / inline error,禁止只 console.error
- 成功必须反馈:操作成功后给用户明确提示(
notifySuccess('创建成功')),否则用户不确定是否生效 - 危险操作二次确认:删除、批量操作、不可逆操作
- 防重复提交:变更 loading 独立 + 按钮 disabled 绑定 loading
3.3 并发请求
Promise.all 并行独立操作:消除瀑布流。串行 await 独立操作会让总耗时变成累加。
const user = await fetchUser();
const posts = await fetchPosts(); // 等了 2 倍时间
const [user, posts] = await Promise.all([fetchUser(), fetchPosts()]); // 并行
廉价条件检查先行:在 await 前做廉价同步检查,避免无谓请求。
async function load(id: string) {
if (!id) return null; // 廉价检查
const data = await fetch(id); // 再 await
return data;
}
推迟 await 到使用点:早启动 promise,晚 await。Promise 构造时就开始执行,await 只阻塞取结果。
const userPromise = fetchUser(); // 立即启动
const postsPromise = fetchPosts();
// 其他同步工作...
const user = await userPromise; // 此处才阻塞
const posts = await postsPromise;
§4 决策树速查
状态选型
状态需要跨页面共享?
├─ 是 -> 状态库
│ ├─ 需要 localStorage 持久化?-> persist + partialize
│ └─ 嵌套深更新?-> immer
└─ 否 -> 状态逻辑复杂(多字段联动/状态机)?
├─ 是 -> useReducer
└─ 否 -> 单字段独立?-> useState
├─ 跨层级共享但低频?-> Context
└─ 用户可分享/可恢复?-> URL 状态
请求模式
请求是查询还是变更?
├─ 查询(GET 语义)
│ ├─ 列表/筛选 -> auto + refreshDeps + ready
│ ├─ 详情(点击触发)-> manual + run(id)
│ └─ 轮询 -> auto + pollingInterval
└─ 变更(POST/PUT/DELETE 语义)
├─ 独立 loading + try/finally + refresh 联动
└─ 危险操作 -> 二次确认
Hook 拆分
页面是 CRUD 列表?
├─ 是 -> 有 ≥3 个高度相似实体共享流程?
│ ├─ 是 -> 风格 C(依赖注入式)+ ADR 记录
│ └─ 否 -> 页面简单(1 查询 + ≤2 mutation + ≤1 dialog + 字段 ≤15)?
│ ├─ 是 -> 风格 A(合并式)
│ └─ 否 -> 风格 B(拆分式,默认)
└─ 否(详情页/配置页/只读页)-> 按职责拆多个单一 hook
状态边界
状态作用域?
├─ 跨页面共享 -> 状态库
├─ 仅本页面 -> 页面 hooks + useState
├─ 仅组件内部 -> useState
├─ 表单字段 -> Form 状态管理(不进状态库)
└─ 用户可分享 -> URL 状态
§5 禁止项速查
状态管理
create<any>或省略 store 类型——破坏类型安全- 组件内
useXxxStore()整体订阅——任何字段变化都触发重渲染 - 在 store 中写 JSX——store 不应感知 React 组件
- 把页面局部状态(表单临时值、弹窗 open)放进全局状态库——污染 + 难清理
- 用 Context 替代状态库做全局状态——Context 应用于依赖注入,无法按字段订阅
- 派生状态用 effect 同步——额外渲染 + 状态漂移
- useState 非函数式更新导致 stale closure——回调引用不稳定 + 读到过期值
- 多个 store 互相 import 导致循环依赖
- 持久化 store 不写 partialize——瞬态字段(loading/error)被持久化
- 异步 action 不处理 loading/error——UI 永久卡 loading 或静默失败
数据请求
useEffect(() => { fetch(...).then(setData) }, [])模式——用请求库,不要手写- 变更请求不设 manual——自动执行会误触发
- 变更成功后不调
refresh()——UI 数据不一致 - 静默吞错(
.catch(() => {})无提示)——用户不知操作失败 - 操作无成功反馈——用户不确定是否生效
- 多个变更操作共享一个 loading 状态——无法区分哪个操作在进行
refreshDeps用对象引用——无限请求,用 useMemo 稳定- 把 useRequest 当缓存层用(跨组件共享数据)——useRequest 无跨组件缓存/去重,此类场景选 SWR/React Query
- 手写 AbortController 替代请求库(除非明确性能瓶颈)
useEffect + setTimeout模拟 debounce——用库内置- 组件内
setInterval轮询——用请求库pollingInterval - 缺失三态(loading/error/empty)——边界场景用户困惑
数据流编排
- list hook 内写 mutation——违反风格 B 职责分离
- mutations hook 内自动执行查询——违反风格 B 职责分离
- mutations hook 内部直接调 list hook 的 refresh——用 onSuccess 注入,避免循环依赖
- 不满足例外条件擅自用风格 A 或 C——默认用 B
- Hook 返回值不显式定义 interface——调用方拿到 any
- 串行 await 独立操作——用 Promise.all 并行