欢迎光临
我们一直在努力

React 查询状态机:loading、empty、error 不要互相打架

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 QueryState<T> =
| { tag: "idle" }
| { tag: "loading" }
| { tag: "success"; data: T; refreshing: boolean }
| { tag: "empty" }
| { tag: "error"; message: string }
| { tag: "errorWithStaleData"; data: T; message: string }

联合类型的好处是把非法组合挡在类型层。例如 error 状态不应该同时携带 refreshing: true,empty 状态也不应该携带一组空数组再让视图自己猜。组件渲染时只关心 tag,每个分支拿到的字段都是合法的。

function renderUsers(state: QueryState<User[]>) {
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: QueryState<User[]>, newFilters: Filters): QueryState<User[]> {
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: UseQueryResult<User[]>): QueryState<User[]> {
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 和刷新失败等状态定义清楚,再用类型约束非法组合。

查询组件稳定,不是因为判断写得多,而是因为状态空间被设计过。状态机越清楚,页面越少出现互相打架的视觉反馈。

赞(0)
未经允许不得转载:171主机测评 » React 查询状态机:loading、empty、error 不要互相打架
分享到: 更多 (0)

评论 抢沙发

  • 昵称 (必填)
  • 邮箱 (必填)
  • 网址