framework
framework/react/component/state-completeness.md
medium
react-state-completeness
组件状态完备 — 默认/悬停/焦点/激活/加载/禁用/错误/空态。Use when 写 React 组件 / 改 .tsx 文件 / 评审涉及 `state-completeness` 的 PR。
状态hoverfocusdisabledloadingempty
paths
frontend/src/components/**/*.tsxfrontend/src/features/**/*.tsx
React · 8 种状态完备
规则
每个交互组件必须显式实现 8 种状态,缺一不可:
| # | 状态 | 触发 | 视觉 |
|---|---|---|---|
| 1 | 默认 | 加载完成无交互 | 标准 border + bg |
| 2 | 悬停 | mouse enter | border 加深 + transform translateY(-2px) |
| 3 | 焦点 | tab 聚焦 | outline 2px accent |
| 4 | 激活 | mouse down / 选中 | border-color: accent + bg: accent-50 |
| 5 | 加载 | data fetching | Skeleton 占位 / Spin 蒙层 |
| 6 | 禁用 | aria-disabled / disabled | opacity 0.5 + cursor: not-allowed |
| 7 | 错误 | API 失败 / 校验失败 | red border + error message |
| 8 | 空态 | 数据 0 行 | EmptyState 组件 + CTA |
模板
function ArticleList() {
const { data, isLoading, error, refetch } = useQuery({
queryKey: ["articles"],
queryFn: () => articlesApi.list(),
});
// 5. 加载
if (isLoading) return <SkeletonGrid count={6} />;
// 7. 错误
if (error) {
return (
<ErrorState
title="加载失败"
description={error.message}
action={<Button onClick={() => refetch()}>重试</Button>}
/>
);
}
// 8. 空态
if (!data || data.length === 0) {
return (
<EmptyState
icon={<BookIcon />}
title="还没有内容"
description="浏览目录开始你的第一份内容"
action={<Button type="primary">浏览目录</Button>}
/>
);
}
// 1-4. 默认 / 悬停 / 焦点 / 激活 在每个卡片内
return (
<div className="grid">
{data.map(item => <ArticleCard key={item.id} article={item} />)}
</div>
);
}
反例
// ❌ 只考虑成功路径
if (!data) return null; // 加载 / 错误 / 空态全没有
return <Grid items={data} />;
// ❌ 加载用旋转 spinner 占满屏(用户不知道结构)
if (isLoading) return <FullScreenSpin />;
// ✅ 用 Skeleton 保留结构
if (isLoading) return <SkeletonGrid count={6} />;
推荐公共组件
<Skeleton>/<SkeletonGrid>—frontend/src/components/Skeleton.tsx<ErrorState>—frontend/src/components/ErrorState.tsx<EmptyState>—frontend/src/features/dashboard/EmptyState.tsx
自检
- [ ] 8 种状态各有实现(或明确决策"本组件无 X 状态因为 Y")?
- [ ] 加载用 Skeleton 不用全屏 Spin?
- [ ] 错误状态可重试(不是死页)?
- [ ] 空态有 CTA 引导用户下一步?