React 查询状态机loading、empty、error 不要互相打架一、查询状态不是三个布尔值很多 React 页面会把接口状态写成三个布尔值loading、error、empty。一开始这样写很快但页面复杂后就容易出现互相打架的状态请求还在 loading却已经展示 empty接口失败了但旧数据还留在页面上用户点击重试时错误提示和骨架屏同时出现。查询状态应该被当成一个状态机而不是几个随手追加的变量。尤其是列表、报表、详情页这类常见业务界面用户看到的不只是数据还包括等待、失败、空结果、刷新和保留旧数据的过程。二、先定义页面允许出现的状态stateDiagram-v2 [*] -- idle idle -- loading loading -- success loading -- empty loading -- error success -- refreshing refreshing -- success refreshing -- errorWithStaleData error -- loading一个查询页面至少要区分初次加载和后台刷新。初次加载没有旧数据适合展示骨架屏后台刷新已有旧数据直接清空页面会让用户产生跳动感。错误也要分两类完全没有数据时的错误和有旧数据但刷新失败的错误。如果这些状态没有提前定义组件里就会出现很多条件判断if (loading !data) return Skeleton / if (error !data) return ErrorView / if (!loading data?.length 0) return Empty /这段代码看似清楚但随着筛选、分页、重试、自动刷新加入判断会越来越难维护。三、用联合类型收敛非法组合type QueryStateT | { tag: idle } | { tag: loading } | { tag: success; data: T; refreshing: boolean } | { tag: empty } | { tag: error; message: string } | { tag: errorWithStaleData; data: T; message: string }联合类型的好处是把非法组合挡在类型层。例如error状态不应该同时携带refreshing: trueempty状态也不应该携带一组空数组再让视图自己猜。组件渲染时只关心tag每个分支拿到的字段都是合法的。function renderUsers(state: QueryStateUser[]) { switch (state.tag) { case loading: return UserSkeleton / case empty: return EmptyUsers / case error: return ErrorPanel message{state.message} / case errorWithStaleData: return UserTable rows{state.data} warning{state.message} / case success: return UserTable rows{state.data} busy{state.refreshing} / } }这类写法也方便 AI 辅助生成测试。因为每个状态都有明确输入模型不需要猜某个布尔值组合到底代表什么。联合类型定义好之后还有一个容易被忽略的细节筛选条件变化时的状态迁移。用户在列表页切换筛选条件时应该回到 loading 状态还是直接清空旧数据直接清空会让页面内容突然消失但保留旧数据再显示 loading 又会让人困惑。更稳的做法是区分“从 success 重新加载”和“初次进入”function applyFilter(state: QueryStateUser[], newFilters: Filters): QueryStateUser[] { if (state.tag success || state.tag errorWithStaleData) { return { tag: loading, staleData: state.tag success ? state.data : state.data } } return { tag: loading } }这个staleData字段让组件在加载新筛选结果时用户仍然能看到上次的数据。和前文refreshing的设计逻辑一致都是避免页面在用户已经看到内容后突然变空。实际项目里筛选联动如果没有 staleData用户每改一次条件就看到一帧空白体验会很碎。加上之后再用半透明遮罩或骨架叠在旧数据上反馈感就会自然很多。四、缓存库也需要页面语义React Query、SWR 这类库已经提供isLoading、isFetching、isError等字段但这些字段是数据层语义不一定等于页面语义。页面仍然要把它们映射成自己的状态机。function toPageState(query: UseQueryResultUser[]): QueryStateUser[] { if (query.isLoading) return { tag: loading } if (query.isError !query.data) return { tag: error, message: 加载失败请重试 } if (query.isError query.data) { return { tag: errorWithStaleData, data: query.data, message: 刷新失败当前展示旧数据 } } if (!query.data || query.data.length 0) return { tag: empty } return { tag: success, data: query.data, refreshing: query.isFetching } }工程里还要约定验收口径初次加载是否允许展示旧缓存筛选条件变化时是否清空列表分页失败是否回退页码自动刷新失败是否弹 toast。这些不是库能替你决定的事而是产品体验和前端状态共同决定的边界。分页失败的回退尤其容易被忽略。用户从第 2 页翻第 3 页接口返回了 500。此时如果页码已经更新到 3但数据还停在旧页用户的认知就会分裂。更合理的做法是分页请求失败时页码不递增并在当前页顶部弹出轻量提示第 3 页加载失败已停留在当前页而不是把整个列表切到 error 状态。五、总结React 查询状态要从页面语义出发把 loading、empty、error、success 和刷新失败等状态定义清楚再用类型约束非法组合。查询组件稳定不是因为判断写得多而是因为状态空间被设计过。状态机越清楚页面越少出现互相打架的视觉反馈。