Loading / Success / Error 适合描述一次请求,却经常无法描述一个真实页面:旧数据还在展示时可以刷新,刷新可能失败,筛选条件仍要保留,错误提示只出现一次。本文用 TypeScript 和 React 构造一份不可变的页面快照,把长期数据、并发过程和一次性消息分开建模,并给出 reducer、渲染规则、竞态处理与测试方法。核心目标不是增加状态数量,而是让无效组合更难产生,让用户在弱网和重试场景下仍能看到稳定界面。
趋势观察
2026-08-03 的掘金文章综合榜中,"别再只用 Loading、Success、Error 表示所有页面状态了"位列第 7。原帖从 Jetpack Compose 的 Feed 页面出发,指出初次加载、保留旧内容的刷新、筛选条件和非阻塞错误不是同一个维度。这个问题并不限于 Compose,在 React、Vue、Flutter 和服务端模板中都会出现。
三态模型究竟错在哪里
下面的联合类型本身没有错误:
ts
type PageState<T> =
| { type: "loading" }
| { type: "success"; data: T }
| { type: "error"; message: string };
它适合"进入页面后只请求一次,结果互斥"的详情页。问题出在把它套到所有页面上。以列表为例,用户已经看到 20 条数据并触发刷新时,页面同时具有两个事实:
- 当前有可展示的数据;
- 一次刷新请求正在进行。
若切到 loading,旧内容会消失;若仍叫 success,刷新指示器又无处保存。继续添加 refreshing、refreshFailed、loadingMore 会让状态数量按组合膨胀。改成一组布尔值也不理想,因为 isInitialLoading && hasData && fatalError 之类的无效组合可以被随手构造。
正确的问题不是"还要加几个状态",而是"页面包含哪些相互独立的事实"。
按生命周期拆成三类信息
一份可渲染的页面快照通常包含三类信息。
1. 长期数据
内容、筛选条件、分页游标等会持续存在,直到下一次业务事件改变它们。
2. 过程状态
初次加载、刷新、加载更多是可以并存或互斥的过程。它们需要精确描述范围,而不是共用一个 isLoading。
3. 一次性消息
Toast、Snackbar、跳转指令不应伪装成长期页面状态。若只用一个 error?: string,组件重挂载或屏幕旋转后可能重复展示。消息需要身份,并在消费后确认。
ts
type InitialLoad =
| { type: "idle" }
| { type: "loading" }
| { type: "failed"; reason: string }
| { type: "ready" };
type AsyncJob =
| { type: "idle" }
| { type: "running"; requestId: string };
type UiMessage = {
id: string;
level: "info" | "error";
text: string;
};
type FeedState<Item> = Readonly<{
items: readonly Item[];
filter: "all" | "following";
initialLoad: InitialLoad;
refresh: AsyncJob;
loadMore: AsyncJob;
nextCursor: string | null;
messages: readonly UiMessage[];
}>;
这里仍然使用联合类型,但只把真正互斥的小阶段放进联合类型。页面整体则是一张可以同时容纳多个事实的快照。
用事件驱动更新,别让组件随意改字段
状态结构清楚后,还要收紧修改入口。组件发出业务事件,reducer 负责维护不变量:
ts
type FeedEvent<Item> =
| { type: "initialRequested" }
| { type: "initialSucceeded"; items: readonly Item[]; cursor: string | null }
| { type: "initialFailed"; reason: string }
| { type: "refreshRequested"; requestId: string }
| {
type: "refreshSucceeded";
requestId: string;
items: readonly Item[];
cursor: string | null;
}
| { type: "refreshFailed"; requestId: string; reason: string }
| { type: "messageConsumed"; id: string };
function reduceFeed<Item>(
state: FeedState<Item>,
event: FeedEvent<Item>,
): FeedState<Item> {
switch (event.type) {
case "initialRequested":
return { ...state, initialLoad: { type: "loading" } };
case "initialSucceeded":
return {
...state,
items: [...event.items],
nextCursor: event.cursor,
initialLoad: { type: "ready" },
};
case "initialFailed":
return {
...state,
initialLoad: { type: "failed", reason: event.reason },
};
case "refreshRequested":
return {
...state,
refresh: { type: "running", requestId: event.requestId },
};
case "refreshSucceeded":
if (
state.refresh.type !== "running" ||
state.refresh.requestId !== event.requestId
) {
return state; // 丢弃过期响应
}
return {
...state,
items: [...event.items],
nextCursor: event.cursor,
initialLoad: { type: "ready" },
refresh: { type: "idle" },
};
case "refreshFailed":
if (
state.refresh.type !== "running" ||
state.refresh.requestId !== event.requestId
) {
return state;
}
return {
...state,
refresh: { type: "idle" },
messages: [
...state.messages,
{ id: crypto.randomUUID(), level: "error", text: event.reason },
],
};
case "messageConsumed":
return {
...state,
messages: state.messages.filter((item) => item.id !== event.id),
};
}
}
requestId 很重要。用户连续切换筛选条件时,较早的请求可能较晚返回;如果不核对身份,旧响应会覆盖新页面。也可以用 AbortController 主动取消,但服务端可能已经完成请求,客户端仍应防御过期结果。
渲染规则应由事实推导
不要再保存 showEmpty、showFullScreenError 等可计算字段,否则同一事实会有两份来源。把渲染条件写成纯函数:
ts
function deriveView<Item>(state: FeedState<Item>) {
const hasContent = state.items.length > 0;
return {
showSkeleton:
!hasContent && state.initialLoad.type === "loading",
showFatalError:
!hasContent && state.initialLoad.type === "failed",
showEmpty:
!hasContent && state.initialLoad.type === "ready",
showContent: hasContent,
showRefreshIndicator: state.refresh.type === "running",
};
}
这会自然得到更符合用户预期的行为:
- 首次加载且无缓存时显示骨架屏;
- 首次失败且无内容时显示整页错误;
- 有旧内容时刷新失败,保留列表并展示非阻塞消息;
- 空数组只有在请求成功后才代表"暂无内容",不能与"还没加载"混为一谈。
状态恢复的边界
不是所有字段都该持久化。建议按恢复成本判断:
- 筛选条件、查询词、草稿等用户输入可以写入 URL、路由状态或持久化容器;
- 服务端可重新获取的数据只保留缓存键与时间戳,避免保存过期实体;
refresh.running、Toast 队列等瞬时状态通常不应跨进程恢复;- 涉及支付、提交等操作时,恢复的应是服务端幂等键和业务状态,而不是本地的"正在提交"布尔值。
用状态转移测试代替截图碰运气
reducer 是纯函数,适合用表驱动测试覆盖关键转移:
ts
import { describe, expect, it } from "vitest";
describe("refresh", () => {
it("刷新失败时保留旧数据", () => {
const running = reduceFeed(seedState, {
type: "refreshRequested",
requestId: "r-1",
});
const failed = reduceFeed(running, {
type: "refreshFailed",
requestId: "r-1",
reason: "网络不可用",
});
expect(failed.items).toEqual(seedState.items);
expect(failed.refresh.type).toBe("idle");
expect(failed.messages).toHaveLength(1);
});
it("过期响应不能覆盖新请求", () => {
const running = {
...seedState,
refresh: { type: "running", requestId: "r-2" } as const,
};
const next = reduceFeed(running, {
type: "refreshSucceeded",
requestId: "r-1",
items: [],
cursor: null,
});
expect(next).toBe(running);
});
});
测试至少覆盖:初次成功、初次失败、旧内容刷新、重复点击、乱序响应、空结果、消息消费和组件重挂载。
边界条件
三态模型并非应该被彻底淘汰。支付结果页、只读详情页、一次性导入任务等阶段真正互斥时,密封联合类型更简洁。不要为了"架构完整"给只有一个按钮的页面引入 reducer。
另一个边界是缓存。缓存命中不等于数据最新,建议单独记录 updatedAt 或缓存策略,而不是把缓存数据标成永恒的 success。若页面允许并行刷新和加载更多,还要明确二者是否能同时运行,以及刷新成功后是否使旧分页游标失效。
总结
页面状态不是一次网络请求的返回值,而是 UI 在某一时刻完成渲染所需的全部事实。把长期数据、并发过程和一次性消息分开,用事件维护状态不变量,用请求身份抵御竞态,再从事实推导渲染条件,复杂页面就不必靠不断追加枚举或布尔值维持。Loading / Success / Error 仍然有用,但它应该描述确实互斥的阶段,而不是强行代表整个产品页面。