react-data-flow

9 阅读12分钟

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 四态——缺失任何一态都会让用户在边界场景困惑(卡白屏、报错无提示、空数据无引导)。

状态实现
loadingloading 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: 300useDebounceFn,避免每次击键请求

手写 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 并行