components-hierarchy
Compare original and translation side by side
🇺🇸
Original
English🇨🇳
Translation
ChineseComponent Conventions (React + TypeScript + Tailwind)
组件规范(React + TypeScript + Tailwind)
Use this skill when creating, modifying, or reviewing any UI component (). It captures an atomic-design hierarchy, a shared utility, variant/size dictionaries, polymorphic component patterns, icon usage, composition slots, and Tailwind styling conventions.
*.tsxclassNamesNotation. Wherever a path appears as,<components>/, or<utils>/, treat it as a placeholder. Resolve to the project's existing paths and aliases (<types>/,src/components,app/components,~/lib/utils, etc.). The shape of the convention matters; the exact location does not.@/types
Precedence. These rules apply only where they don't contradict the project's own enforced rules, ESLint / Biome / Prettier configs,,tsconfig/CLAUDE.md, contributing guides, or stylelint. If a project rule conflicts, the project rule wins; defer to it and (if it makes sense) note the deviation in your PR description.AGENTS.md
Framework / runtime. §1-§10 and §13 are framework-agnostic, they apply to any React + TypeScript + Tailwind setup, including a plain Vite + React SPA with no SSR, no RSC, and no router opinions. §11 (SSR-unsafe components), §12 (multilingual), and parts of §14 (RSC / App Router / Server Components / form actions /performance helpers) are optional and clearly flagged, skip them on SPAs. The core idioms (atomic hierarchy,next/*, variant tokens, dictionaries, polymorphicclassNames, slots, React 19asprop, hooks discipline) are universal.ref
TRIGGER when: adding a new atom/molecule/organism, refactoring an existing , wiring up icons, choosing between /variant/size props, or reviewing component PRs.
*.tsxas在创建、修改或评审任何UI组件()时遵循本规范。它包含原子设计层级结构、共享工具、变体/尺寸字典、多态组件模式、图标使用规范、组合插槽以及Tailwind样式约定。
*.tsxclassNames符号说明。当路径以、<components>/或<utils>/形式出现时,将其视为占位符,替换为项目中实际的路径和别名(如<types>/、src/components、app/components、~/lib/utils等)。规范的结构至关重要,具体位置可灵活调整。@/types
优先级。本规范仅在不与项目自身强制执行的规则、ESLint/Biome/Prettier配置、、tsconfig/CLAUDE.md、贡献指南或stylelint冲突时适用。若项目规则与本规范冲突,以项目规则为准;如有必要,可在PR描述中注明差异。AGENTS.md
框架/运行时。§1-§10和§13是框架无关的,适用于任何React + TypeScript + Tailwind环境,包括无SSR、无RSC且无路由依赖的纯Vite + React SPA。§11(SSR不安全组件)、§12(多语言支持)以及§14的部分内容(RSC/应用路由/服务器组件/表单动作/性能助手)是可选的,且已明确标记,纯SPA项目可跳过。核心规范(原子层级、next/*、变体令牌、字典、多态classNames、插槽、React 19as属性、钩子规范)是通用的。ref
触发场景:添加新的原子/分子/组织组件、重构现有文件、配置图标、选择/变体/尺寸属性、评审组件PR时。
*.tsxas1. Atomic Hierarchy
1. 原子层级结构
<components>/
atoms/ single-purpose primitives (Button, Input, Text, Card, Badge, ...)
molecules/ small compositions of atoms (ModalDialog, DropdownButton, ...)
organisms/ domain-aware blocks (grouped by domain subfolder)
layouts/ page chrome (NavBar, Footer, SideBar, ...)
sections/ marketing/landing page sections (Hero, Features, ...)<components>/
atoms/ 单一用途的基础组件(Button、Input、Text、Card、Badge等)
molecules/ 由原子组件组合而成的小型组件(ModalDialog、DropdownButton等)
organisms/ 业务领域相关的组件块(按业务领域子文件夹分组)
layouts/ 页面框架组件(NavBar、Footer、SideBar等)
sections/ 营销/落地页区块(Hero、Features等)Rules
规则
- A component must import only from its layer or below: . Never go upward.
atoms ← molecules ← organisms ← layouts ← sections - Filename pattern: . If the project uses a category suffix (e.g.
PascalCase.tsx), keep it consistent across the layer.Button.ui.tsx - Each folder has an barrel re-exporting every
index.ts. Add new files to the barrel in the same change..tsx - Organisms are typically grouped by domain subfolder () with their own
organisms/<domain>/.index.ts - Test files live in next to the component (e.g.
__tests__/).molecules/__tests__ - Domain-aware components live in or below, never in
organisms/.atoms/molecules
- 组件只能从自身层级或更低层级导入:,禁止向上导入。
atoms ← molecules ← organisms ← layouts ← sections - 文件名格式:。若项目使用类别后缀(如
PascalCase.tsx),需在整个层级中保持一致。Button.ui.tsx - 每个文件夹包含一个文件,统一导出所有
index.ts组件。添加新文件时需同步更新该导出文件。.tsx - Organisms组件通常按业务领域子文件夹分组(),并拥有各自的
organisms/<domain>/文件。index.ts - 测试文件存放在组件旁的目录中(如
__tests__/)。molecules/__tests__ - 业务领域相关组件应放在或更低层级,禁止放在
organisms/中。atoms/molecules
Imports
导入示例
ts
// Always root-relative; never "../../"
import { Button, Tooltip, type ButtonProps } from "components/atoms";
import { ModalDialog } from "components/molecules";
import { classNames } from "utils";
import { SizeVariants, ColorVariants, type SizeVariant, type ColorVariant } from "types/variants";Use for type-only imports. Respect the project's import grouping (typically external → internal alias → local).
import { type Foo }ts
// 始终使用根相对路径;禁止使用"../../"
import { Button, Tooltip, type ButtonProps } from "components/atoms";
import { ModalDialog } from "components/molecules";
import { classNames } from "utils";
import { SizeVariants, ColorVariants, type SizeVariant, type ColorVariant } from "types/variants";仅类型导入使用格式。遵循项目的导入分组规则(通常为外部依赖 → 内部别名 → 本地文件)。
import { type Foo }2. The classNames
Utility (NOT clsx
)
classNamesclsx2. classNames
工具(禁止使用clsx
)
classNamesclsxDefined at :
<utils>/classNames.tsts
export function classNames(...classes: (string | undefined | null | false)[]) {
return classes.filter(Boolean).join(" ");
}定义在:
<utils>/classNames.tsts
export function classNames(...classes: (string | undefined | null | false)[]) {
return classes.filter(Boolean).join(" ");
}Rules
规则
- Never import or
clsxfrom npm in a project that uses this convention. Always use the localclassnamesfromclassNames.<utils> - Pass each Tailwind class as its own argument, do not pre-join with spaces.
- Conditional classes: ternary returning a string or /
"".false - Spread arrays produced by Size/Color dictionaries: .
...SizeDict[size] - Always pass the user's prop last so consumers can override.
className
tsx
className={classNames(
"inline-flex",
"items-center",
"rounded-lg",
...SizeDict[size],
...ColorDict[variant],
disabled ? "cursor-not-allowed opacity-50" : "",
block ? "w-full" : "",
className // consumer override, always last
)}Exception: A component can use a template literal because its class set is trivial. Preferfor anything non-trivial, hard limit is 4 css classes applied or moreclassNames
- 在使用本规范的项目中,禁止从npm导入或
clsx,必须使用classnames中的本地<utils>。classNames - 每个Tailwind类作为单独的参数传入,禁止预先用空格拼接。
- 条件类:使用三元表达式返回字符串或/
""。false - 展开尺寸/颜色字典生成的数组:。
...SizeDict[size] - 始终将用户传入的属性放在最后,以便消费者覆盖样式。
className
tsx
className={classNames(
"inline-flex",
"items-center",
"rounded-lg",
...SizeDict[size],
...ColorDict[variant],
disabled ? "cursor-not-allowed opacity-50" : "",
block ? "w-full" : "",
className // 消费者覆盖,始终放在最后
)}例外:若组件的类集合非常简单,可使用模板字符串。对于非简单场景,优先使用,当应用的CSS类数量超过4个时,强制使用classNames。classNames
3. Shared Variant Tokens
3. 共享变体令牌
Defined at as objects + literal-union types (not TypeScript , emits runtime code, is rejected by TS 5.5+ and Node's native TS loader, and adds no value here since prop types are string-literal unions anyway):
<types>/variants.tsconstenumenum--erasableSyntaxOnlyts
export const ColorVariants = {
primary: "primary",
secondary:"secondary",
plain: "plain",
outlined: "outlined",
danger: "danger",
warning: "warning",
success: "success",
info: "info"
} as const;
export type ColorVariant = (typeof ColorVariants)[keyof typeof ColorVariants];
export const SizeVariants = {
tiny: "tiny",
small: "small",
base: "base",
medium: "medium",
large: "large"
} as const;
export type SizeVariant = (typeof SizeVariants)[keyof typeof SizeVariants];定义在中,为**对象 + 字面量联合类型**(禁止使用TypeScript ,因为会生成运行时代码,被TS 5.5+的和Node原生TS加载器拒绝,且在此场景下,属性类型为字符串字面量联合类型,无法提供额外价值):
<types>/variants.tsconstenumenum--erasableSyntaxOnlyenumts
export const ColorVariants = {
primary: "primary",
secondary:"secondary",
plain: "plain",
outlined: "outlined",
danger: "danger",
warning: "warning",
success: "success",
info: "info"
} as const;
export type ColorVariant = (typeof ColorVariants)[keyof typeof ColorVariants];
export const SizeVariants = {
tiny: "tiny",
small: "small",
base: "base",
medium: "medium",
large: "large"
} as const;
export type SizeVariant = (typeof SizeVariants)[keyof typeof SizeVariants];Rules
规则
- Use these tokens for any prop that conceptually maps to color/intent or size.
- Prop type is the derived /
ColorVariantunion (orSizeVariantif you prefer the key form). Both are equivalent for string-valued const objects.keyof typeof ColorVariants - Never invent new local size/color string unions when these would fit. If you need a subset, narrow with .
Extract<ColorVariant, "info" | "danger" | "warning" | "success"> - Default values reference the const object: ,
size = SizeVariants.medium.variant = ColorVariants.primary
Migrating from? A string-valuedenumobject with literal-union type is a drop-in replacement at all call sites that already usedconst, no consumer changes needed.keyof typeof X
- 任何与颜色/意图或尺寸相关的属性,都应使用这些令牌。
- 属性类型为派生的/
ColorVariant联合类型(若偏好键形式,也可使用SizeVariant)。对于字符串值的const对象,两者等价。keyof typeof ColorVariants - 禁止在这些令牌适用的场景下,创建新的本地尺寸/颜色字符串联合类型。若需要子集,使用进行筛选。
Extract<ColorVariant, "info" | "danger" | "warning" | "success"> - 默认值引用const对象:,
size = SizeVariants.medium。variant = ColorVariants.primary
从迁移? 字符串值的enum对象搭配字面量联合类型,可直接替换所有使用const的调用点,无需修改消费者代码。keyof typeof X
4. Size & Color Dictionaries
4. 尺寸与颜色字典
Map variant keys to arrays of Tailwind classes, declared at module scope (not inside the component):
tsx
const SizeDict: Record<SizeVariant, string[]> = {
tiny: ["px-3", "py-2", "text-xs"],
small: ["px-3", "py-1"],
base: ["text-sm", "px-5", "py-2.5"],
medium: ["text-sm", "px-5", "py-2.5"],
large: ["px-2", "py-1"]
};
// Example tokens for a dark-mode-only baseline. For a light/dark project,
// pair every surface/text class with a `dark:` counterpart.
const BadgeColorTypeMap: Record<BadgeType, string[]> = {
info: ["bg-blue-900", "text-blue-300"],
dark: ["bg-gray-700", "text-gray-300"],
success: ["bg-green-900", "text-green-300"],
warning: ["bg-yellow-900", "text-yellow-300"],
error: ["bg-red-900", "text-red-300"],
main: ["bg-indigo-600", "text-white"]
};将变体键映射为Tailwind类数组,声明在模块作用域(禁止在组件内部声明):
tsx
const SizeDict: Record<SizeVariant, string[]> = {
tiny: ["px-3", "py-2", "text-xs"],
small: ["px-3", "py-1"],
base: ["text-sm", "px-5", "py-2.5"],
medium: ["text-sm", "px-5", "py-2.5"],
large: ["px-2", "py-1"]
};
// 仅暗黑模式基准的示例令牌。若项目支持明暗双模式,需为每个背景/文本类搭配`dark:`前缀的对应类。
const BadgeColorTypeMap: Record<BadgeType, string[]> = {
info: ["bg-blue-900", "text-blue-300"],
dark: ["bg-gray-700", "text-gray-300"],
success: ["bg-green-900", "text-green-300"],
warning: ["bg-yellow-900", "text-yellow-300"],
error: ["bg-red-900", "text-red-300"],
main: ["bg-indigo-600", "text-white"]
};Color via switch (when conditional / depends on other props)
条件颜色(依赖其他属性时使用switch)
When color depends on more than the variant key alone (e.g. needs base styles merged in), use a closure that returns :
getXStyle()string[]tsx
const getButtonColorStyle = (): string[] => {
const baseStyle = group ? baseGroupButtonStyle : baseButtonStyle;
switch (variant) {
case ColorVariants.primary:
return [...baseStyle, "bg-indigo-700", "hover:bg-indigo-900", "focus:ring-indigo-900"];
case ColorVariants.danger:
return [...baseStyle, "bg-red-700", "hover:bg-red-900", "focus:ring-red-900"];
case ColorVariants.plain:
return ["text-white"];
default:
return [...baseStyle, "text-white", "bg-slate-700", "hover:bg-slate-600"];
}
};当颜色不仅依赖变体键(例如需要合并基础样式)时,使用返回的闭包:
string[]getXStyle()tsx
const getButtonColorStyle = (): string[] => {
const baseStyle = group ? baseGroupButtonStyle : baseButtonStyle;
switch (variant) {
case ColorVariants.primary:
return [...baseStyle, "bg-indigo-700", "hover:bg-indigo-900", "focus:ring-indigo-900"];
case ColorVariants.danger:
return [...baseStyle, "bg-red-700", "hover:bg-red-900", "focus:ring-red-900"];
case ColorVariants.plain:
return ["text-white"];
default:
return [...baseStyle, "text-white", "bg-slate-700", "hover:bg-slate-600"];
}
};Rules
规则
- Dict name: ,
SizeDict, orColorDict, singular, scoped to the component file.XColorTypeMap - Values are so they can be spread (
string[])....SizeDict[size] - Static base class arrays use or
SCREAMING_CASEand live above the dict.baseXStyle - Prefer dictionaries over switches when the lookup is purely keyed; reserve for cases that need to combine bases or share fallthroughs.
switch
- 字典名称:、
SizeDict或ColorDict,单数形式,作用域限定在组件文件内。XColorTypeMap - 值类型为,以便可以展开(
string[])。...SizeDict[size] - 静态基础类数组使用或
SCREAMING_CASE命名,放在字典上方。baseXStyle - 当仅通过键查找时,优先使用字典;仅当需要合并基础样式或共享默认分支时,才使用。
switch
5. Standard Prop Conventions
5. 标准属性约定
Every visual component supports:
| Prop | Type | Notes |
|---|---|---|
| | Always merged last in |
| | Default |
| | Default |
| | Adds |
| | Adds |
| discriminated literal union | See §7 Polymorphism |
| via | Default rendering slot |
| | Composition slots, see §8 |
每个可视化组件都应支持以下属性:
| 属性 | 类型 | 说明 |
|---|---|---|
| | 在 |
| | 默认值 |
| | 默认值 |
| | 添加 |
| | 添加 |
| 可区分的字面量联合类型 | 参见§7 多态性 |
| 通过 | 默认渲染插槽 |
| | 组合插槽,参见§8 |
Native attribute extension
原生属性扩展
Always extend the matching native HTML attributes interface so the component is a drop-in:
tsx
export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
variant?: ColorVariant;
size?: SizeVariant;
group?: boolean;
block?: boolean;
}
export interface InputProps extends InputHTMLAttributes<HTMLInputElement> {
label: string;
labelVisible?: boolean;
invalid?: boolean;
inputSize?: SizeVariant;
}Spread / onto the underlying element so handlers, , , , , etc., all just work.
...attributes...inputPropsaria-*data-*nameid始终扩展匹配的原生HTML属性接口,使组件可以直接替代原生元素:
tsx
export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
variant?: ColorVariant;
size?: SizeVariant;
group?: boolean;
block?: boolean;
}
export interface InputProps extends InputHTMLAttributes<HTMLInputElement> {
label: string;
labelVisible?: boolean;
invalid?: boolean;
inputSize?: SizeVariant;
}将/展开到底层元素上,使事件处理器、、、、等属性自动生效。
...attributes...inputPropsaria-*data-*nameidDiscriminated optional groups
可区分的可选属性组
When a feature requires multiple props together, model it as a discriminated union, not multiple optional props:
tsx
type BadgeProps = {
type?: BadgeType;
size?: SizeVariant;
} & (
| { handleRemove: (id: string) => void; badgeId: string; removeTextStyle: string }
| { handleRemove?: never; badgeId?: never; removeTextStyle?: never }
);This forces consumers to provide either the whole group or none of it.
当某个功能需要多个属性配合使用时,将其建模为可区分的联合类型,而非多个可选属性:
tsx
type BadgeProps = {
type?: BadgeType;
size?: SizeVariant;
} & (
| { handleRemove: (id: string) => void; badgeId: string; removeTextStyle: string }
| { handleRemove?: never; badgeId?: never; removeTextStyle?: never }
);这会强制消费者要么提供整个属性组,要么完全不提供。
ref
as a regular prop (React 19)
refref
作为常规属性(React 19)
refrefforwardRefRef<HTMLElement>tsx
export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
ref?: Ref<HTMLButtonElement>;
variant?: ColorVariant;
size?: SizeVariant;
}
export function Button({
ref,
variant = "primary",
size = SizeVariants.medium,
className,
children,
...rest
}: PropsWithChildren<ButtonProps>) {
return <button ref={ref} {...rest}>{children}</button>;
}Migrate existing atoms opportunistically when you touch them. Use callback refs (with optional cleanup return) when the parent needs to react to mount/unmount.
forwardRef在函数组件中,是一个常规属性,新代码中禁止使用。在属性接口中将其类型声明为:
refforwardRefRef<HTMLElement>tsx
export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
ref?: Ref<HTMLButtonElement>;
variant?: ColorVariant;
size?: SizeVariant;
}
export function Button({
ref,
variant = "primary",
size = SizeVariants.medium,
className,
children,
...rest
}: PropsWithChildren<ButtonProps>) {
return <button ref={ref} {...rest}>{children}</button>;
}在修改现有原子组件时,可适时迁移代码。当父组件需要响应挂载/卸载时,使用回调ref(可返回清理函数)。
forwardRef6. Defaults & Readonly
Readonly6. 默认值与Readonly
Readonly- Provide defaults in the destructuring signature: ,
variant = "primary",size = SizeVariants.medium.bold = false - Wrap props in when the component does not mutate its props, most components qualify.
Readonly<PropsWithChildren<...>>
- 在解构签名中提供默认值:、
variant = "primary"、size = SizeVariants.medium。bold = false - 当组件不修改属性时,将属性包裹在中,大多数组件都符合此条件。
Readonly<PropsWithChildren<...>>
7. Polymorphism via as
Prop
as7. 通过as
属性实现多态性
asWhen a component should render as different elements depending on context (link vs button vs section), use a discriminated union rather than :
asElementTypetsx
type CardProps = (
| (ButtonHTMLAttributes<HTMLButtonElement> & { as: "button" })
| (AnchorHTMLAttributes<HTMLAnchorElement> & { as: "a" })
| (HTMLAttributes<HTMLElement> & { as: "section" })
) & PropsWithChildren<{
size?: SizeVariant;
variant?: ColorVariant;
// ...other shared props
}>;
export function Card({ as, children, ...otherProps }: CardProps) {
const CardWrapper: ElementType = as ?? "section";
return <CardWrapper {...otherProps}>{children}</CardWrapper>;
}Each branch carries the correct HTML attribute set, so consumers get autocompleted only when and typed for the right event when .
hrefas="a"onClickas="button"当组件需要根据上下文渲染为不同元素(链接、按钮、区块等)时,使用可区分的联合类型而非:
asElementTypetsx
type CardProps = (
| (ButtonHTMLAttributes<HTMLButtonElement> & { as: "button" })
| (AnchorHTMLAttributes<HTMLAnchorElement> & { as: "a" })
| (HTMLAttributes<HTMLElement> & { as: "section" })
) & PropsWithChildren<{
size?: SizeVariant;
variant?: ColorVariant;
// ...其他共享属性
}>;
export function Card({ as, children, ...otherProps }: CardProps) {
const CardWrapper: ElementType = as ?? "section";
return <CardWrapper {...otherProps}>{children}</CardWrapper>;
}每个分支都携带正确的HTML属性集,因此当时,消费者会自动补全属性;当时,会被正确类型化为对应事件。
as="a"hrefas="button"onClickRules
规则
- Limit to a small set of literals (
as). Don't accept arbitrary"button" | "a" | "section"at the prop boundary, that defeats type narrowing on the union.ElementType - Pick a default branch (typically or
"section") via"div"and assign to aas ?? "section"local typed asCapitalizedso JSX accepts it.ElementType - Native attribute interface must match the literal (↔
"a").AnchorHTMLAttributes<HTMLAnchorElement>
- 将限制为少量字面量(
as)。禁止在属性边界接受任意"button" | "a" | "section",这会破坏联合类型的类型收窄能力。ElementType - 通过选择默认分支(通常为
as ?? "section"或"section"),并赋值给类型为"div"的大驼峰本地变量,以便JSX可以接受它。ElementType - 原生属性接口必须与字面量匹配(↔
"a")。AnchorHTMLAttributes<HTMLAnchorElement>
8. Composition Slots
8. 组合插槽
For non-trivial components, expose named slot props of type instead of cramming everything into . Convention: slot prop names are PascalCase.
ReactNodechildrentsx
interface ModalDialogProps {
Title?: ReactNode; // header content
Actions?: ReactNode; // footer content
visible: boolean;
// ...
}
<ModalDialog
visible={open}
Title={<h2>Confirm</h2>}
Actions={
<>
<Button variant="plain" onClick={cancel}>Cancel</Button>
<Button variant="danger" onClick={confirm}>Delete</Button>
</>
}
>
Are you sure?
</ModalDialog>对于复杂组件,暴露命名插槽属性(类型为),而非将所有内容塞进。约定:插槽属性名使用大驼峰。
ReactNodechildrentsx
interface ModalDialogProps {
Title?: ReactNode; // 头部内容
Actions?: ReactNode; // 底部操作内容
visible: boolean;
// ...
}
<ModalDialog
visible={open}
Title={<h2>确认</h2>}
Actions={
<>
<Button variant="plain" onClick={cancel}>取消</Button>
<Button variant="danger" onClick={confirm}>删除</Button>
</>
}
>
您确定要执行此操作吗?
</ModalDialog>Rules
规则
- PascalCase slot prop names (,
Title,Actions), distinguishes them from primitive props.TitleElement - is reserved for the body / primary content.
children - For compound components (/
Tabs,Tab/Accordion), share coordination state via a React Context scoped to the parent, read withAccordionItemor React 19'suseContext. This is what Radix, Headless UI, React Aria, shadcn/ui, and the React docs all do. Reach foruse()+Children.maponly as a last resort when you specifically need positional info (cloneElement/isFirst) and the children are guaranteed to be direct, non-wrapped, non-conditionalisLastelements.<Tab>
- 插槽属性名使用大驼峰(、
Title、Actions),与基础属性区分开。TitleElement - 保留用于主体/主要内容。
children - 对于复合组件(/
Tabs、Tab/Accordion),通过父组件作用域内的React Context共享协调状态,使用AccordionItem或React 19的useContext读取状态。这是Radix、Headless UI、React Aria、shadcn/ui以及React文档推荐的方式。仅当明确需要位置信息(use()/isFirst)且子元素是直接的、未包裹的、非条件的isLast元素时,才作为最后手段使用<Tab>+Children.map。cloneElement
9. Icons
9. 图标
Two parallel systems, pick the right one per case.
提供两套并行系统,根据场景选择合适的方案。
9.1 FontAwesome (default)
9.1 FontAwesome(默认)
tsx
import { FontAwesomeIcon, type FontAwesomeIconProps } from "@fortawesome/react-fontawesome";
// Use whichever icon package(s) the project licenses.
import { faCheck, faTimes, faSpinner } from "<primary-icon-pkg>";
import { faPlus } from "<secondary-icon-pkg>";
<FontAwesomeIcon icon={faCheck} className="text-green-400" />
<FontAwesomeIcon icon={faSpinner} spin />
<FontAwesomeIcon icon={faTimes} size="lg" />tsx
import { FontAwesomeIcon, type FontAwesomeIconProps } from "@fortawesome/react-fontawesome";
// 使用项目授权的任意图标包。
import { faCheck, faTimes, faSpinner } from "<primary-icon-pkg>";
import { faPlus } from "<secondary-icon-pkg>";
<FontAwesomeIcon icon={faCheck} className="text-green-400" />
<FontAwesomeIcon icon={faSpinner} spin />
<FontAwesomeIcon icon={faTimes} size="lg" />Rules
规则
-
Pick one default icon set and use it everywhere. Use a secondary set only when the default lacks the right glyph or the alt set is the right semantic.
-
Don't mix paid and free tiers of the same library, pick one source per project.
-
Forward icon props through wrappers viaand
FontAwesomeIconProps["icon"]:FontAwesomeIconProps["size"]tsxinterface ButtonIconTooltipProps { icon: FontAwesomeIconProps["icon"]; size?: FontAwesomeIconProps["size"]; } -
Color via Tailwind(
className,"text-green-400"), not the FontAwesome"text-red-400"prop.color -
For loading states use the library's spin variant (e.g.). Don't combine
<FontAwesomeIcon icon={faSpinner} spin />withspin,pulseis FA4's legacy 8-step jump and produces a stuttery animation when paired withpulse.spin -
When an icon-only button needs accessible text, add.
<span className="sr-only">{label}</span>
-
选择一套默认图标集并统一使用。仅当默认图标集缺少合适的图标或替代集更符合语义时,才使用第二套图标集。
-
禁止混合使用同一库的付费版和免费版,每个项目选择一个来源。
-
通过和
FontAwesomeIconProps["icon"]传递图标属性:FontAwesomeIconProps["size"]tsxinterface ButtonIconTooltipProps { icon: FontAwesomeIconProps["icon"]; size?: FontAwesomeIconProps["size"]; } -
通过Tailwind设置颜色(
className、"text-green-400"),禁止使用FontAwesome的"text-red-400"属性。color -
加载状态使用库的spin变体(如)。禁止将
<FontAwesomeIcon icon={faSpinner} spin />与spin组合使用,pulse是FA4的遗留8步跳跃动画,与pulse搭配会产生卡顿效果。spin -
当仅含图标的按钮需要可访问文本时,添加。
<span className="sr-only">{label}</span>
9.2 Inline SVG illustrations
9.2 内联SVG插图
Brand illustrations and bespoke graphics live in as React components. They:
<components>/atoms/icons/- Export a zero-prop function returning a single JSX literal.
<svg> - Are not parameterized by size/color, they're large brand SVGs.
- Are imported from a separate barrel from regular atoms.
Use these for marketing/illustration only. For action/status icons always use the icon library.
品牌插图和定制图形作为React组件存放在中,需满足:
<components>/atoms/icons/- 导出一个无参数的函数,返回单个JSX字面量。
<svg> - 不通过尺寸/颜色参数化,它们是大型品牌SVG。
- 从独立的导出文件导入,与常规原子组件区分开。
仅将其用于营销/插图场景。操作/状态图标始终使用图标库。
10. Styling Conventions
10. 样式约定
-
Tailwind only, no CSS Modules, no styled-components, no inlineexcept for genuinely dynamic values (e.g.
style, computed colors).objectFit -
Pick a theme baseline. If the app is dark-mode-only, default to,
bg-gray-700/800/900,text-white,text-gray-300/400and don't addborder-gray-600/700prefixes. If the app supports both themes, define the baseline in light tokens and pair every surface/text token with adark:counterpart.dark: -
Brand color, pick one of two Tailwind-native paths and stick to it:
- Use a built-in palette directly (,
indigo,emerald,blue, …). Simplest, matches the Tailwind UI / Tailwind Plus convention. Examples in this doc useslate.indigo - Extend the theme with a project-named palette via (v3) or
theme.extend.colors.<name>(v4). Common names:@theme { --color-<name>-50…950: … },primary, or the brand's own name. Avoidaccentonly if your design system already uses it for something else.brand
Whichever path you pick, primary actions use the/700shades:900/bg-<palette>-700/hover:bg-<palette>-900. Don't mix paths within one project.focus:ring-<palette>-900 - Use a built-in palette directly (
-
Focus states:.
focus:outline-none focus:ring-4 focus:ring-<color> -
Disabled state:.
cursor-not-allowed opacity-50 -
Radius:for buttons/cards/dialogs,
rounded-lgfor pills/avatars,rounded-fullfor tags.rounded -
Spacing scale anchors: padding(medium control),
px-5 py-2.5(input),p-2.5(modal section).p-4 md:p-6 -
Typography: prefer aatom over hand-written
Texton rawtext-2xl font-boldin new code,<h2>.<Text as="h2" variant="headline" bold>
-
仅使用Tailwind,禁止使用CSS Modules、styled-components,除了真正的动态值(如、计算颜色)外,禁止使用内联
objectFit。style -
选择主题基准。若应用仅支持暗黑模式,默认使用、
bg-gray-700/800/900、text-white、text-gray-300/400,无需添加border-gray-600/700前缀。若应用支持双模式,在亮色令牌中定义基准,并为每个背景/文本令牌搭配dark:前缀的对应类。dark: -
品牌颜色,选择以下两种Tailwind原生方案之一并保持一致:
- 直接使用内置调色板(、
indigo、emerald、blue等)。最简单,符合Tailwind UI/Tailwind Plus约定。本文档示例使用slate。indigo - 扩展主题,通过(v3)或
theme.extend.colors.<name>(v4)添加项目命名的调色板。常见名称:@theme { --color-<name>-50…950: … }、primary或品牌自身名称。仅当设计系统已将accent用于其他用途时,才避免使用该名称。brand
无论选择哪种方案,主要操作都使用/700色调:900/bg-<palette>-700/hover:bg-<palette>-900。同一项目内禁止混合使用两种方案。focus:ring-<palette>-900 - 直接使用内置调色板(
-
焦点状态:。
focus:outline-none focus:ring-4 focus:ring-<color> -
禁用状态:。
cursor-not-allowed opacity-50 -
圆角:按钮/卡片/对话框使用,药丸/头像使用
rounded-lg,标签使用rounded-full。rounded -
间距基准:内边距(中等控件)、
px-5 py-2.5(输入框)、p-2.5(模态框区块)。p-4 md:p-6 -
排版:在新代码中,优先使用原子组件,而非在原始
Text上手动编写<h2>,例如text-2xl font-bold。<Text as="h2" variant="headline" bold>
11. SSR-unsafe components (optional)
11. SSR不安全组件(可选)
Plain Vite SPAs and other SPA-only setups can skip this entire section, there's no server-render pass, so / access at module load is harmless.
windowdocumentFor SSR / SSG / RSC frameworks, any component depending on libraries that touch / at module load ( / , MapLibre, certain charting libs, etc.) must be loaded as a client-only chunk:
windowdocumentleafletreact-leaflettsx
// Next.js
const Map = dynamic(() => import("components/molecules/Map").then((m) => m.Map), {
ssr: false
});
// TanStack Start
const Map = lazy(() => import("components/molecules/Map").then((m) => ({ default: m.Map })));
// ...render under <ClientOnly fallback={<MapSkeleton />}>{<Map />}</ClientOnly>
// Plain React.lazy works too, just don't render the lazy component on the server pass.Such components should also be excluded from any barrel that is imported on the server.
纯Vite SPA和其他仅SPA的项目可跳过本节,因为没有服务器渲染过程,在模块加载时访问/是安全的。
windowdocument对于SSR/SSG/RSC框架,任何依赖在模块加载时访问/的库(/、MapLibre、某些图表库等)的组件,必须作为仅客户端代码块加载:
windowdocumentleafletreact-leaflettsx
// Next.js
const Map = dynamic(() => import("components/molecules/Map").then((m) => m.Map), {
ssr: false
});
// TanStack Start
const Map = lazy(() => import("components/molecules/Map").then((m) => ({ default: m.Map })));
// ...在<ClientOnly fallback={<MapSkeleton />}>{<Map />}</ClientOnly>下渲染
// 纯React.lazy也可使用,只需确保不在服务器渲染过程中渲染懒加载组件。此类组件还应排除在任何会被服务器端导入的导出文件之外。
12. Multilingual Text (optional)
12. 多语言文本(可选)
Skip if the project is single-language.
If the project stores i18n content as a per-language map (), pick one language as the required default and treat all others as nullable. Inside components, read the current language from the project's i18n source (Redux, Context, , , etc.) and always fall back to the required language, never render .
{ text: { en, es, ... } }next-intlreact-i18nextundefinedtsx
// Shape only, wire to whichever i18n source the project actually uses.
const language = useCurrentLanguage();
const label = entity.text[language] ?? entity.text[DEFAULT_LANGUAGE];If the project uses message-catalog i18n () instead of per-record maps, this section does not apply, use the catalog API.
t("key")若项目为单语言,可跳过本节。
若项目将i18n内容存储为按语言划分的映射(),选择一种语言作为必填默认语言,其他语言视为可选。在组件内部,从项目的i18n源(Redux、Context、、等)读取当前语言,并始终回退到必填默认语言,禁止渲染。
{ text: { en, es, ... } }next-intlreact-i18nextundefinedtsx
// 仅示例结构,需根据项目实际使用的i18n源进行配置。
const language = useCurrentLanguage();
const label = entity.text[language] ?? entity.text[DEFAULT_LANGUAGE];若项目使用消息目录i18n()而非每条记录的映射,本节不适用,使用目录API即可。
t("key")13. Worked Example, A Button Wrapper Atom (React 19)
13. 示例:IconButton包装原子组件(React 19)
tsx
import { type PropsWithChildren, type ButtonHTMLAttributes, type Ref } from "react";
import { FontAwesomeIcon, type FontAwesomeIconProps } from "@fortawesome/react-fontawesome";
import { Button, type ButtonProps } from "components/atoms";
import { ColorVariants, SizeVariants, type SizeVariant } from "types/variants";
import { classNames } from "utils";
// Reuses the base `Button` atom (defined in §5).
export interface IconButtonProps extends Omit<ButtonProps, "size" | "ref"> {
ref?: Ref<HTMLButtonElement>;
icon: FontAwesomeIconProps["icon"];
iconPosition?: "left" | "right";
size?: SizeVariant;
label: string; // sr-only fallback when icon-only
}
const IconGapDict: Record<SizeVariant, string> = {
tiny: "gap-1",
small: "gap-1.5",
base: "gap-2",
medium: "gap-2",
large: "gap-2.5"
};
export function IconButton({
ref,
icon,
iconPosition = "left",
size = SizeVariants.medium,
variant = ColorVariants.primary,
label,
children,
className,
...rest
}: PropsWithChildren<IconButtonProps>) {
return (
<Button
ref={ref}
size={size}
variant={variant}
className={classNames("inline-flex", "items-center", IconGapDict[size], className)}
{...rest}
>
{iconPosition === "left" ? <FontAwesomeIcon icon={icon} /> : null}
{children ? <span>{children}</span> : <span className="sr-only">{label}</span>}
{iconPosition === "right" ? <FontAwesomeIcon icon={icon} /> : null}
</Button>
);
}This example demonstrates: React 19 as a normal prop, native attribute extension via , FontAwesome icon prop forwarding, size dictionary, accessible fallback label, no manual memoization (compiler handles it), and merged last.
refOmit<ButtonProps, "size" | "ref">classNametsx
import { type PropsWithChildren, type ButtonHTMLAttributes, type Ref } from "react";
import { FontAwesomeIcon, type FontAwesomeIconProps } from "@fortawesome/react-fontawesome";
import { Button, type ButtonProps } from "components/atoms";
import { ColorVariants, SizeVariants, type SizeVariant } from "types/variants";
import { classNames } from "utils";
// 复用基础`Button`原子组件(定义在§5)。
export interface IconButtonProps extends Omit<ButtonProps, "size" | "ref"> {
ref?: Ref<HTMLButtonElement>;
icon: FontAwesomeIconProps["icon"];
iconPosition?: "left" | "right";
size?: SizeVariant;
label: string; // 仅图标按钮的sr-only回退文本
}
const IconGapDict: Record<SizeVariant, string> = {
tiny: "gap-1",
small: "gap-1.5",
base: "gap-2",
medium: "gap-2",
large: "gap-2.5"
};
export function IconButton({
ref,
icon,
iconPosition = "left",
size = SizeVariants.medium,
variant = ColorVariants.primary,
label,
children,
className,
...rest
}: PropsWithChildren<IconButtonProps>) {
return (
<Button
ref={ref}
size={size}
variant={variant}
className={classNames("inline-flex", "items-center", IconGapDict[size], className)}
{...rest}
>
{iconPosition === "left" ? <FontAwesomeIcon icon={icon} /> : null}
{children ? <span>{children}</span> : <span className="sr-only">{label}</span>}
{iconPosition === "right" ? <FontAwesomeIcon icon={icon} /> : null}
</Button>
);
}此示例展示:React 19中作为常规属性使用、通过扩展原生属性、FontAwesome图标属性传递、尺寸字典、可访问回退标签、无需手动 memoization(编译器处理)、最后合并。
refOmit<ButtonProps, "size" | "ref">className14. React 19 Patterns
14. React 19模式
Author components against React 19. The React Compiler handles most memoization, the new ref/form/transition APIs replace several legacy patterns, and is no longer needed for new components.
forwardRefApplies to any React 19 setup, Vite SPA, Next.js (App or Pages Router), Remix, TanStack Start, etc. Subsections labeled "App Router / RSC only" are framework-specific; skip them on a plain Vite SPA. Everything else (compiler, refs, hooks,, accessibility, performance, TypeScript) is universal.use()
基于React 19编写组件。React编译器处理大多数memoization,新的ref/表单/过渡API替代了多个遗留模式,新组件不再需要。
forwardRef适用于任何React 19环境,包括Vite SPA、Next.js(应用路由或页面路由)、Remix、TanStack Start等。标记为*"App Router / RSC only"*的小节是框架特定的;纯Vite SPA项目可跳过。其他内容(编译器、refs、钩子、、可访问性、性能、TypeScript)是通用的。use()
Compiler-first mindset
编译器优先思维
Applies only if the React Compiler is actually enabled in the build (wired into Babel/SWC, or the Next.jsbabel-plugin-react-compilerflag, etc.). On a vanilla React 19 project without the compiler, the rules below will produce real perf regressions, keep your existing memoization until the compiler is turned on.experimental.reactCompiler
- With the compiler enabled, components and hook outputs are auto-memoized. Stop hand-rolling /
useMemo/useCallbackfor normal cases, write straightforward code and let the compiler optimize it.React.memo - Only reach for manual memoization when:
- The compiler bails out (verified via the warnings or React DevTools "✨" badge absence) and a profiler shows a real regression.
react-compiler-runtime - You're integrating with a non-compiled third-party that requires referential stability.
- The compiler bails out (verified via the
- Inline object/array/function props as children are fine under the compiler, don't contort code to hoist them.
- Keep components idempotent (the compiler assumes purity): no mutation of props/state in render, no /
Math.random()in render bodies, no side effects outside event handlers / effects / refs. These purity rules also matter under React 19 Strict Mode regardless of the compiler.Date.now()
仅当构建中实际启用了React编译器时适用(集成到Babel/SWC,或Next.js的babel-plugin-react-compiler标志等)。在未启用编译器的原生React 19项目中,以下规则会导致性能下降,在编译器启用前保留现有的memoization。experimental.reactCompiler
- 启用编译器后,组件和钩子输出会自动memoize。**停止手动编写/
useMemo/useCallback**处理常规情况,编写简洁代码即可,由编译器负责优化。React.memo - 仅在以下情况使用手动memoization:
- 编译器退出优化(通过警告或React DevTools中缺少"✨"徽章验证),且性能分析显示实际性能下降。
react-compiler-runtime - 与未编译的第三方库集成,且该库需要引用稳定性。
- 编译器退出优化(通过
- 在编译器下,作为子元素的内联对象/数组/函数属性是安全的,无需为了提升而扭曲代码。
- 保持组件幂等(编译器假设组件是纯函数):渲染过程中不修改属性/状态,渲染体中不使用/
Math.random(),事件处理器/副作用/refs之外无副作用。这些纯函数规则在React 19严格模式下同样重要,无论是否启用编译器。Date.now()
ref
as a regular prop (drop forwardRef
)
refforwardRefref
作为常规属性(弃用forwardRef
)
refforwardRefIn React 19, is a normal prop on function components. Do not use in new code. Migrate atoms opportunistically.
refforwardReftsx
// React 19 idiom, no forwardRef
export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
ref?: Ref<HTMLButtonElement>;
variant?: ColorVariant;
size?: SizeVariant;
}
export function Button({
ref,
variant = "primary",
size = SizeVariants.medium,
className,
children,
...rest
}: PropsWithChildren<ButtonProps>) {
return (
<button ref={ref} className={classNames(/* ... */, className)} {...rest}>
{children}
</button>
);
}- Use callback refs with cleanup (React 19 supports a returned cleanup function) for DOM measurement / external library attach:
tsx
<div ref={(node) => { if (!node) return; const obs = new ResizeObserver(/* ... */); obs.observe(node); return () => obs.disconnect(); }} /> - is still valid when exposing a curated handle, but prefer plain
useImperativeHandleforwarding.ref
在React 19中,是函数组件的常规属性。新代码中禁止使用。适时迁移原子组件。
refforwardReftsx
// React 19规范,无需forwardRef
export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
ref?: Ref<HTMLButtonElement>;
variant?: ColorVariant;
size?: SizeVariant;
}
export function Button({
ref,
variant = "primary",
size = SizeVariants.medium,
className,
children,
...rest
}: PropsWithChildren<ButtonProps>) {
return (
<button ref={ref} className={classNames(/* ... */, className)} {...rest}>
{children}
</button>
);
}- 对于DOM测量/外部库挂载,使用带清理功能的回调ref(React 19支持返回清理函数):
tsx
<div ref={(node) => { if (!node) return; const obs = new ResizeObserver(/* ... */); obs.observe(node); return () => obs.disconnect(); }} /> - 在暴露定制句柄时仍然有效,但优先使用纯
useImperativeHandle转发。ref
Hooks discipline
钩子规范
- Treat as a last resort. Before reaching for it, ask:
useEffect- Can this be derived during render? (compute from props/state)
- Can this be done in an event handler?
- Is it server state? → Use the project's data-fetching layer (TanStack Query, SWR, RSC , etc.).
await - Is it shared UI state? → Use the project's existing store (Redux, Zustand, Context). Don't introduce a new mechanism for trivial cases.
- Use for label/
useId()/htmlFor/aria-describedbypairings. Never roll counters oraria-labelledbyids.Math.random() - Use for non-React stores (
useSyncExternalStore, custom event buses). Don't reimplement withmatchMedia+useEffect.useState - Use /
useDeferredValueto keep input responsive when derivation/list filtering is expensive. Wrap the derivation, not the input.useTransition - setter accepts a function for derived updates:
useState, required when the next value depends on the previous and updates may batch.setCount((c) => c + 1)
- 将作为最后手段。在使用前,先问自己:
useEffect- 是否可以在渲染过程中派生?(从属性/状态计算)
- 是否可以在事件处理器中完成?
- 是否是服务器状态?→ 使用项目的数据获取层(TanStack Query、SWR、RSC 等)。
await - 是否是共享UI状态?→ 使用项目现有的状态管理工具(Redux、Zustand、Context)。不要为琐碎的情况引入新机制。
- 使用****处理标签/
useId()/htmlFor/aria-describedby配对。禁止使用计数器或aria-labelledby生成id。Math.random() - 使用****处理非React状态(
useSyncExternalStore、自定义事件总线)。禁止使用matchMedia+useEffect重新实现。useState - 使用**/
useDeferredValue**在派生/列表过滤开销较大时保持输入响应性。包装派生逻辑,而非输入组件。useTransition - setter接受函数用于派生更新:
useState,当下一个值依赖于前一个值且更新可能批量处理时,必须使用此方式。setCount((c) => c + 1)
use()
, read promises and context conditionally
use()use()
,条件读取Promise和Context
use()React 19's reads a promise or context and can be called inside conditionals/loops (unlike other hooks).
use()tsx
import { use, Suspense } from "react";
function Title({ promise }: { promise: Promise<{ title: string }> }) {
const data = use(promise); // suspends until resolved
return <h1>{data.title}</h1>;
}
// Wrap with Suspense for loading UI:
<Suspense fallback={<SkeletonCard />}>
<Title promise={titlePromise} />
</Suspense>- Prefer the project's data-fetching layer for client data caching. is for promises produced by the server (RSC) or for one-shot client work that shouldn't go through a cache.
use() - is the new way to read context conditionally;
use(SomeContext)still works for unconditional reads.useContext
React 19的可以读取Promise或Context,且可以在条件/循环中调用(与其他钩子不同)。
use()tsx
import { use, Suspense } from "react";
function Title({ promise }: { promise: Promise<{ title: string }> }) {
const data = use(promise); // 暂停直到Promise resolved
return <h1>{data.title}</h1>;
}
// 使用Suspense包裹以显示加载UI:
<Suspense fallback={<SkeletonCard />}>
<Title promise={titlePromise} />
</Suspense>- 优先使用项目的数据获取层处理客户端数据缓存。适用于服务器生成的Promise(RSC)或不应通过缓存处理的一次性客户端操作。
use() - 是条件读取Context的新方式;
use(SomeContext)仍适用于无条件读取。useContext
Actions, transitions, and form hooks
动作、过渡和表单钩子
/useActionState/useFormStatusare usable in any React 19 environment (Vite SPA included), they don't strictly require RSC. They shine brightest with Server Actions, but in a plain SPA you can pass anyuseOptimisticreducer and get the same pending/state ergonomics.(state, formData) => Promise<state>is fully framework-agnostic.useTransition
In a form, prefer the React 19 primitives over hand-rolled loading state.
- , bind a
useActionStatereducer to a(state, formData) => newState; React handles pending state and (in RSC frameworks) progressive enhancement.<form>tsx// "use client" is needed only in RSC frameworks (Next.js App Router, etc.). // Plain SPA (Vite, etc.): drop the directive, the file is already a client module. import { useActionState } from "react"; export function Subscribe({ action }: { action: (s: State, fd: FormData) => Promise<State> }) { const [state, submit, isPending] = useActionState(action, { error: null }); return ( <form action={submit}> <input name="email" required /> <Button type="submit" disabled={isPending}>Subscribe</Button> {state.error ? <Alert variant="danger">{state.error}</Alert> : null} </form> ); } - , read the parent
useFormStatus's pending state from a child without prop-drilling.<form>tsximport { useFormStatus } from "react-dom"; export function SubmitButton({ children }: PropsWithChildren) { const { pending } = useFormStatus(); return <Button type="submit" disabled={pending}>{children}</Button>; } - , show optimistic UI while a mutation is in flight.
useOptimistictsxconst [optimisticItems, addOptimistic] = useOptimistic(items, (state, next: Item) => [...state, next]); - , wrap async non-urgent updates:
useTransition.startTransition(async () => { await save(); /* navigate or refresh */ })
If the project has both an App Router and a Pages Router (or equivalent split), apply these hooks where the React 19 runtime is wired up. For non-React-19 trees, drive submit buttons off the project's existing mutation state (e.g.).mutation.isPending
/useActionState/useFormStatus可在任何React 19环境中使用(包括Vite SPA),它们并不严格依赖RSC。在Server Actions中表现最佳,但在纯SPA中,您可以传递任何useOptimisticreducer,获得相同的pending/状态管理体验。**(state, formData) => Promise<state>**完全与框架无关。useTransition
在表单中,优先使用React 19原语,而非手动实现加载状态。
- ,将
useActionStatereducer绑定到(state, formData) => newState;React处理pending状态,且在RSC框架中支持渐进式增强。<form>tsx// 仅在RSC框架(Next.js应用路由等)中需要"use client"指令。 // 纯SPA(Vite等):删除该指令,文件已属于客户端模块。 import { useActionState } from "react"; export function Subscribe({ action }: { action: (s: State, fd: FormData) => Promise<State> }) { const [state, submit, isPending] = useActionState(action, { error: null }); return ( <form action={submit}> <input name="email" required /> <Button type="submit" disabled={isPending}>订阅</Button> {state.error ? <Alert variant="danger">{state.error}</Alert> : null} </form> ); } - ,从子组件读取父
useFormStatus的pending状态,无需属性透传。<form>tsximport { useFormStatus } from "react-dom"; export function SubmitButton({ children }: PropsWithChildren) { const { pending } = useFormStatus(); return <Button type="submit" disabled={pending}>{children}</Button>; } - ,在mutation进行时显示乐观UI。
useOptimistictsxconst [optimisticItems, addOptimistic] = useOptimistic(items, (state, next: Item) => [...state, next]); - ,包裹异步非紧急更新:
useTransition。startTransition(async () => { await save(); /* 导航或刷新 */ })
若项目同时包含应用路由和页面路由(或类似拆分),在React 19运行时集成的地方应用这些钩子。对于非React 19树,使用项目现有的mutation状态(如)控制提交按钮状态。mutation.isPending
Server vs Client Components, Next.js App Router / RSC frameworks only
服务器组件 vs 客户端组件,仅Next.js应用路由/RSC框架适用
Skip entirely if the project is a plain SPA (Vite, etc.) or Pages-Router-only, every component is a Client Component by definition; the / directives don't apply.
"use client""use server"- Default App Router files are Server Components, no hooks, no event handlers, no browser APIs. They can data and render directly.
await - Add only when the file uses hooks, event handlers, browser APIs, or imports a client-only lib.
"use client" - Keep boundaries as leaves. Server Components can render Client Components, but Client Components can only render Server Components passed as
"use client"/slot props (children).<ClientShell>{serverNode}</ClientShell> - Server Actions () live in App Router action files only. Plain async functions in your data layer are not Server Actions, don't add the directive there.
"use server"
若项目为纯SPA(Vite等)或仅使用页面路由,可完全跳过本节,所有组件默认都是客户端组件;/指令不适用。
"use client""use server"- 应用路由文件默认是服务器组件,不能使用钩子、事件处理器或浏览器API。它们可以数据并直接渲染。
await - 仅当文件使用钩子、事件处理器、浏览器API或导入仅客户端库时,添加。
"use client" - 保持边界尽可能靠近叶子节点。服务器组件可以渲染客户端组件,但客户端组件只能渲染作为
"use client"/插槽属性传递的服务器组件(children)。<ClientShell>{serverNode}</ClientShell> - Server Actions()仅存放在应用路由的action文件中。数据层中的普通异步函数不是Server Actions,不要为其添加该指令。
"use server"
Suspense & errors
Suspense与错误处理
- Wrap lazy-loaded chunks (,
React.lazy, framework equivalents),next/dynamicpromises, and data-loading subtrees inuse(), never bare spinners.<Suspense fallback={<SkeletonCard />} /> - Wrap risky subtrees (map, payment iframe, third-party widgets) in an error boundary that reports to the project's error-tracking tool (Sentry, etc.). React 19's /
onUncaughtErroronCaughtErroroptions can centralize reporting.createRoot - Don't throw promises manually, let , the lazy loader, or the data-fetching layer manage suspension.
use()
- 使用包裹懒加载代码块(
<Suspense fallback={<SkeletonCard />} />、React.lazy、框架等效方法)、next/dynamicPromise和数据加载子树,禁止使用裸加载指示器。use() - 使用错误边界包裹风险子树(地图、支付iframe、第三方小部件),并上报到项目的错误跟踪工具(Sentry等)。React 19的/
onUncaughtErroronCaughtError选项可集中处理上报。createRoot - 禁止手动抛出Promise,让、懒加载器或数据获取层管理暂停状态。
use()
Document metadata, stylesheets, preloading
文档元数据、样式表、预加载
- React 19 hoists ,
<title>,<meta>, and<link>rendered inside components into<style>, use this for per-component metadata in any framework, including Vite SPAs.<head> - For preconnects / preloads, use the resource APIs:
react-dom. Call from event handlers, or (in RSC frameworks) Server Components, not in render of Client Components.import { preconnect, preload, prefetchDNS } from "react-dom"
- React 19会将组件内渲染的、
<title>、<meta>和<link>提升到<style>,可用于任何框架(包括Vite SPA)中的组件级元数据。<head> - 对于preconnect/preload,使用资源API:
react-dom。从事件处理器调用,或在RSC框架中从服务器组件调用,禁止在客户端组件的渲染过程中调用。import { preconnect, preload, prefetchDNS } from "react-dom"
Accessibility (enforced)
可访问性(强制执行)
- Every interactive element has an accessible name: visible label, , or
aria-label(icon-only buttons).<span className="sr-only">…</span> - always has explicit
<button>, default totype="button" | "submit" | "reset"."button" - Modals use native , keep
<dialog>,aria-modal, andaria-labelhandling.Escape - Focus rings are mandatory: , never strip without replacement.
focus:outline-none focus:ring-4 focus:ring-<color>is preferred over:focus-visiblefor ring states.:focus - Honor reduced motion: gate animations behind or
motion-safe:when using motion libraries.prefers-reduced-motion - Color is never the sole signal, pair status colors with an icon or label.
- Image components require ; for decorative images pass
altexplicitly.alt=""
- 每个交互元素都有可访问名称:可见标签、或
aria-label(仅图标按钮)。<span className="sr-only">…</span> - 始终显式设置
<button>,默认值为type="button" | "submit" | "reset"。"button" - 模态框使用原生,保留
<dialog>、aria-modal和Escape按键处理。aria-label - 焦点环是必需的:,禁止在无替代方案的情况下移除。优先使用
focus:outline-none focus:ring-4 focus:ring-<color>而非:focus-visible处理焦点环状态。:focus - 尊重减少动画偏好:使用动画库时,通过或
motion-safe:控制动画。prefers-reduced-motion - 颜色不能作为唯一信号,状态颜色需搭配图标或标签。
- 图片组件需要属性;装饰性图片显式传递
alt。alt=""
Performance
性能
- For content images, use the framework's optimized image component if there is one (,
next/image,unpic, etc.). On a plain Vite SPA without such a component, raw@unpic/reactis acceptable, pair it with<img>, explicitloading="lazy"/widthto avoid CLS, andheight.decoding="async" - Lazy-load below-the-fold organisms and trees that touch ,
window, or heavy client-only libs. Use the framework's dynamic-import helper if it has one (document), otherwise plainnext/dynamic+React.lazy(() => import("./Heavy"))works in any setup.<Suspense> - Named-import icons (and other tree-shakeable libs), namespace imports defeat tree-shaking.
- Trust the compiler for memoization. Profile before manual .
memo - Avoid synchronous expensive work in render, wrap with or move to a worker.
useDeferredValue - Stable list keys, never array index for reorderable / filterable / paginated lists.
- 对于内容图片,若框架提供优化的图片组件,使用该组件(、
next/image、unpic等)。在无此类组件的纯Vite SPA中,可使用原始@unpic/react,搭配<img>、显式loading="lazy"/width避免CLS,以及height。decoding="async" - 懒加载视口下方的organisms组件和涉及、
window或大型仅客户端库的组件树。若框架提供动态导入助手(如document),使用该助手;否则,纯next/dynamic+React.lazy(() => import("./Heavy"))适用于任何环境。<Suspense> - 命名导入图标(和其他可tree-shake的库),命名空间导入会破坏tree-shaking。
- 依赖编译器进行memoization。在手动使用前先进行性能分析。
memo - 避免在渲染过程中执行同步开销大的操作,使用包裹或移到worker中。
useDeferredValue - 使用稳定的列表键,禁止对可重新排序/过滤/分页的列表使用数组索引作为键。
TypeScript ergonomics
TypeScript ergonomics
- Recommended :
tsconfig+strict, array/dict access isnoUncheckedIndexedAccess. Guard withT | undefinedor?? fallback.if - Prefer for children/slot props;
ReactNodewhen you need toReactElement.cloneElement - for children;
PropsWithChildren<T>when the component does not mutate.Readonly<PropsWithChildren<T>> - Type event handlers explicitly: ,
MouseEventHandler<HTMLButtonElement>. NeverChangeEventHandler<HTMLInputElement>.(e: any) => void - Discriminated unions over when props travel together (see §5).
Partial<T> - prop type is
ref(which isRef<T>). Don't use the legacyRefObject<T> | RefCallback<T> | null/LegacyRef.MutableRefObject
- 推荐配置:
tsconfig+strict,数组/字典访问类型为noUncheckedIndexedAccess。使用T | undefined或?? fallback进行守卫。if - 子元素/插槽属性优先使用;当需要
ReactNode时使用cloneElement。ReactElement - 子元素使用;当组件不修改属性时使用
PropsWithChildren<T>。Readonly<PropsWithChildren<T>> - 显式类型化事件处理器:、
MouseEventHandler<HTMLButtonElement>。禁止使用ChangeEventHandler<HTMLInputElement>。(e: any) => void - 属性组合使用可区分联合类型而非(参见§5)。
Partial<T> - 属性类型为
ref(即Ref<T>)。禁止使用遗留的RefObject<T> | RefCallback<T> | null/LegacyRef。MutableRefObject
State colocation
状态放置
- Lift state only as far as needed.
- Shared UI state across siblings → the project's existing store. Don't spin up a new context for trivial toggles.
- Never mirror server data into a client store, the data-fetching layer is the cache.
- 仅在必要时提升状态。
- 兄弟组件间共享UI状态 → 使用项目现有的状态管理工具。不要为琐碎的切换引入新的Context。
- 禁止将服务器数据镜像到客户端状态,数据获取层作为缓存。
15. Anti-Patterns (Never Do)
15. 反模式(禁止使用)
- ❌ in a project that has the local
import clsx from "clsx", useclassNamesfromclassNames."utils" - ❌ Pre-joined class strings: . Each class is its own arg.
classNames("flex items-center") - ❌ Inventing local size unions, use
"sm" | "md" | "lg".SizeVariants - ❌ Inventing local color string unions, use (or narrow with
ColorVariants).Extract<> - ❌ , use a discriminated literal union.
as: ElementType - ❌ Mixing icon libraries within a single project without a documented reason.
- ❌ Setting icon color via , use Tailwind
color="red".className - ❌ Domain logic / data fetching inside or
atoms/. Hooks belong in organisms or pages.molecules/ - ❌ Adding Tailwind classes in a dark-only project (or omitting them in a light/dark project).
dark: - ❌ Skipping the directory or barrel update when adding a new component.
__tests__ - ❌ Calling raw from a component, use the project's data-fetching layer.
fetch() - ❌ Passing before
className/...SizeDict[size]in...ColorDict[variant], consumer override must come last.classNames(...) - ❌ in new components,
forwardRefis a regular prop in React 19.ref - ❌ Hand-rolled /
useMemo/useCallbackwithout a profiler showing a regression, let the React Compiler do its job.React.memo - ❌ for derived values, event-handler work, or server data fetching.
useEffect - ❌ Wrapping form-hook usages with in non-RSC frameworks (plain Vite SPA, Pages Router, etc.), the directive is meaningless there.
"use client"/useFormStatus/useActionStatework in any React 19 environment.useOptimistic - ❌ Adding to plain async data-layer functions, only RSC framework action files take that directive.
"use server" - ❌ Class components, legacy lifecycle methods, on function components,
defaultProps.propTypes - ❌ Reading /
window/documentat module scope or in render in any code path that runs on the server (SSR / SSG / RSC), guard withlocalStorage, callback ref, or a client-only dynamic import. (Pure SPAs without SSR are fine to read these freely, butuseEffectis still cleaner for reactive sources likeuseSyncExternalStore.)matchMedia - ❌ Array index as for reorderable / filterable lists.
key - ❌ Hand-rolled ids (, counters) for label/aria associations, use
Math.random().useId() - ❌ Namespace imports of icons, always named imports.
- ❌ Stripping /
:focusrings without an accessible replacement.:focus-visible - ❌ without an explicit
<button>.type - ❌ Hidden interactive elements (without
<div onClick={...}>+ keyboard handlers), userole.<button> - ❌ Mutating props or state during render (breaks compiler assumptions and Strict Mode).
- ❌ /
LegacyRefin prop types, useMutableRefObject.Ref<T>
- ❌ 在拥有本地的项目中
classNames,应使用import clsx from "clsx"中的"utils"。classNames - ❌ 预先拼接类字符串:。每个类应作为单独的参数传入。
classNames("flex items-center") - ❌ 创建本地尺寸联合类型,应使用
"sm" | "md" | "lg"。SizeVariants - ❌ 创建本地颜色字符串联合类型,应使用(或使用
ColorVariants筛选)。Extract<> - ❌ 使用,应使用可区分的字面量联合类型。
as: ElementType - ❌ 在单个项目中混合使用图标库,除非有文档记录的理由。
- ❌ 通过设置图标颜色,应使用Tailwind
color="red"。className - ❌ 在或
atoms/中包含业务逻辑/数据获取。钩子应放在organisms或页面中。molecules/ - ❌ 在仅暗黑模式的项目中添加Tailwind类(或在明暗双模式项目中省略)。
dark: - ❌ 添加新组件时跳过目录或导出文件更新。
__tests__ - ❌ 从组件中直接调用,应使用项目的数据获取层。
fetch() - ❌ 在中
classNames(...)放在className/...SizeDict[size]之前,消费者覆盖必须放在最后。...ColorDict[variant] - ❌ 在新组件中使用,React 19中
forwardRef是常规属性。ref - ❌ 在性能分析未显示性能下降的情况下手动编写/
useMemo/useCallback,让React编译器处理。React.memo - ❌ 使用处理派生值、事件处理器工作或服务器数据获取。
useEffect - ❌ 在非RSC框架(纯Vite SPA、页面路由等)中为表单钩子使用指令,该指令在这些环境中无意义。
"use client"/useFormStatus/useActionState可在任何React 19环境中使用。useOptimistic - ❌ 为普通异步数据层函数添加,仅RSC框架的action文件使用该指令。
"use server" - ❌ 类组件、遗留生命周期方法、函数组件的、
defaultProps。propTypes - ❌ 在任何服务器端执行的代码路径(SSR/SSG/RSC)中,在模块作用域或渲染过程中读取/
window/document,使用localStorage、回调ref或仅客户端动态导入进行守卫。(纯无SSR的SPA可自由读取,但对于useEffect等响应式源,matchMedia仍然更简洁。)useSyncExternalStore - ❌ 对可重新排序/过滤的列表使用数组索引作为。
key - ❌ 手动生成id(、计数器)用于标签/aria关联,应使用
Math.random()。useId() - ❌ 命名空间导入图标,始终使用命名导入。
- ❌ 在无替代可访问方案的情况下移除/
:focus环。:focus-visible - ❌ 未设置显式
<button>。type - ❌ 隐藏的交互元素(无
<div onClick={...}>+键盘处理器),应使用role。<button> - ❌ 在渲染过程中修改属性或状态(破坏编译器假设和严格模式)。
- ❌ 在属性类型中使用/
LegacyRef,应使用MutableRefObject。Ref<T>
16. New Component Checklist
16. 新组件检查清单
When adding :
<components>/<layer>/Foo.tsx- Filename matches the project's convention; prefer named over default exports.
export function Foo - Props interface extends matching native ; include
HTMLAttributes<...>andclassName?/variantif visual.size - Module-scope /
SizeDictif the component varies by enum.ColorDict - with each Tailwind class as own arg;
classNames(...)prop merged last.className - Spread props onto the underlying element.
...rest - Default values in the destructuring signature.
- Use (and
PropsWithChildren<...>) where appropriate.Readonly<...> - Add export line to the layer's barrel.
index.ts - If the layer/component has nearby tests, add a .
__tests__/Foo.test.tsx - For polymorphic components, use a discriminated union.
as - If on React 19, use as a regular prop (no
ref).forwardRef
添加时:
<components>/<layer>/Foo.tsx- 文件名符合项目约定;优先使用命名导出而非默认导出。
export function Foo - 属性接口扩展匹配的原生;若为可视化组件,包含
HTMLAttributes<...>和className?/variant。size - 模块作用域内定义/
SizeDict(若组件按枚举变化)。ColorDict - 使用,每个Tailwind类作为单独参数;
classNames(...)属性最后合并。className - 将属性展开到底层元素。
...rest - 在解构签名中设置默认值。
- 适当使用(和
PropsWithChildren<...>)。Readonly<...> - 将导出添加到层级的导出文件中。
index.ts - 若层级/组件附近有测试文件,添加。
__tests__/Foo.test.tsx - 多态组件使用可区分的联合类型。
as - 若使用React 19,将作为常规属性(无需
ref)。forwardRef