components-hierarchy

Compare original and translation side by side

🇺🇸

Original

English
🇨🇳

Translation

Chinese

Component Conventions (React + TypeScript + Tailwind)

组件规范(React + TypeScript + Tailwind)

Use this skill when creating, modifying, or reviewing any UI component (
*.tsx
). It captures an atomic-design hierarchy, a shared
classNames
utility, variant/size dictionaries, polymorphic component patterns, icon usage, composition slots, and Tailwind styling conventions.
Notation. Wherever a path appears as
<components>/
,
<utils>/
, or
<types>/
, treat it as a placeholder. Resolve to the project's existing paths and aliases (
src/components
,
app/components
,
~/lib/utils
,
@/types
, etc.). The shape of the convention matters; the exact location does not.
Precedence. These rules apply only where they don't contradict the project's own enforced rules, ESLint / Biome / Prettier configs,
tsconfig
,
CLAUDE.md
/
AGENTS.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.
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 /
next/*
performance helpers) are optional and clearly flagged, skip them on SPAs. The core idioms (atomic hierarchy,
classNames
, variant tokens, dictionaries, polymorphic
as
, slots, React 19
ref
prop, hooks discipline) are universal.
TRIGGER when: adding a new atom/molecule/organism, refactoring an existing
*.tsx
, wiring up icons, choosing between
as
/variant/size props, or reviewing component PRs.

在创建、修改或评审任何UI组件(
*.tsx
)时遵循本规范。它包含原子设计层级结构、共享
classNames
工具、变体/尺寸字典、多态组件模式、图标使用规范、组合插槽以及Tailwind样式约定。
符号说明。当路径以
<components>/
<utils>/
<types>/
形式出现时,将其视为占位符,替换为项目中实际的路径和别名(如
src/components
app/components
~/lib/utils
@/types
等)。规范的结构至关重要,具体位置可灵活调整。
优先级。本规范仅在不与项目自身强制执行的规则、ESLint/Biome/Prettier配置、
tsconfig
CLAUDE.md
/
AGENTS.md
、贡献指南或stylelint冲突时适用。若项目规则与本规范冲突,以项目规则为准;如有必要,可在PR描述中注明差异。
框架/运行时。§1-§10和§13是框架无关的,适用于任何React + TypeScript + Tailwind环境,包括无SSR、无RSC且无路由依赖的纯Vite + React SPA。§11(SSR不安全组件)、§12(多语言支持)以及§14的部分内容(RSC/应用路由/服务器组件/表单动作/
next/*
性能助手)是可选的,且已明确标记,纯SPA项目可跳过。核心规范(原子层级、
classNames
、变体令牌、字典、多态
as
、插槽、React 19
ref
属性、钩子规范)是通用的。
触发场景:添加新的原子/分子/组织组件、重构现有
*.tsx
文件、配置图标、选择
as
/变体/尺寸属性、评审组件PR时。

1. 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:
    atoms ← molecules ← organisms ← layouts ← sections
    . Never go upward.
  • Filename pattern:
    PascalCase.tsx
    . If the project uses a category suffix (e.g.
    Button.ui.tsx
    ), keep it consistent across the layer.
  • Each folder has an
    index.ts
    barrel re-exporting every
    .tsx
    . Add new files to the barrel in the same change.
  • Organisms are typically grouped by domain subfolder (
    organisms/<domain>/
    ) with their own
    index.ts
    .
  • Test files live in
    __tests__/
    next to the component (e.g.
    molecules/__tests__
    ).
  • Domain-aware components live in
    organisms/
    or below, never in
    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
import { type Foo }
for type-only imports. Respect the project's import grouping (typically external → internal alias → local).

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
)

2.
classNames
工具(禁止使用
clsx

Defined at
<utils>/classNames.ts
:
ts
export function classNames(...classes: (string | undefined | null | false)[]) {
  return classes.filter(Boolean).join(" ");
}
定义在
<utils>/classNames.ts
ts
export function classNames(...classes: (string | undefined | null | false)[]) {
  return classes.filter(Boolean).join(" ");
}

Rules

规则

  • Never import
    clsx
    or
    classnames
    from npm in a project that uses this convention. Always use the local
    classNames
    from
    <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
    className
    prop last so consumers can override.
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. Prefer
classNames
for anything non-trivial, hard limit is 4 css classes applied or more

  • 在使用本规范的项目中,禁止从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          // 消费者覆盖,始终放在最后
)}
例外:若组件的类集合非常简单,可使用模板字符串。对于非简单场景,优先使用
classNames
,当应用的CSS类数量超过4个时,强制使用
classNames

3. Shared Variant Tokens

3. 共享变体令牌

Defined at
<types>/variants.ts
as
const
objects + literal-union types
(not TypeScript
enum
,
enum
emits runtime code, is rejected by TS 5.5+
--erasableSyntaxOnly
and Node's native TS loader, and adds no value here since prop types are string-literal unions anyway):
ts
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];
定义在
<types>/variants.ts
中,为**
const
对象 + 字面量联合类型**(禁止使用TypeScript
enum
,因为
enum
会生成运行时代码,被TS 5.5+的
--erasableSyntaxOnly
和Node原生TS加载器拒绝,且在此场景下,属性类型为字符串字面量联合类型,
enum
无法提供额外价值):
ts
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
    ColorVariant
    /
    SizeVariant
    union (or
    keyof typeof ColorVariants
    if you prefer the key form). Both are equivalent for string-valued const objects.
  • 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
enum
?
A string-valued
const
object with literal-union type is a drop-in replacement at all call sites that already used
keyof typeof X
, no consumer changes needed.

  • 任何与颜色/意图或尺寸相关的属性,都应使用这些令牌
  • 属性类型为派生的
    ColorVariant
    /
    SizeVariant
    联合类型(若偏好键形式,也可使用
    keyof typeof ColorVariants
    )。对于字符串值的const对象,两者等价。
  • 禁止在这些令牌适用的场景下,创建新的本地尺寸/颜色字符串联合类型。若需要子集,使用
    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
getXStyle()
closure that returns
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
    ,
    ColorDict
    , or
    XColorTypeMap
    , singular, scoped to the component file.
  • Values are
    string[]
    so they can be spread (
    ...SizeDict[size]
    ).
  • Static base class arrays use
    SCREAMING_CASE
    or
    baseXStyle
    and live above the dict.
  • Prefer dictionaries over switches when the lookup is purely keyed; reserve
    switch
    for cases that need to combine bases or share fallthroughs.

  • 字典名称:
    SizeDict
    ColorDict
    XColorTypeMap
    ,单数形式,作用域限定在组件文件内。
  • 值类型为
    string[]
    ,以便可以展开(
    ...SizeDict[size]
    )。
  • 静态基础类数组使用
    SCREAMING_CASE
    baseXStyle
    命名,放在字典上方。
  • 当仅通过键查找时,优先使用字典;仅当需要合并基础样式或共享默认分支时,才使用
    switch

5. Standard Prop Conventions

5. 标准属性约定

Every visual component supports:
PropTypeNotes
className
string
(optional)
Always merged last in
classNames(...)
variant
ColorVariant
Default
"primary"
(or local subset)
size
SizeVariant
Default
SizeVariants.medium
or
.base
disabled
boolean
Adds
cursor-not-allowed opacity-50
block
boolean
Adds
w-full
as
discriminated literal unionSee §7 Polymorphism
children
via
PropsWithChildren<...>
Default rendering slot
<Slot>
ReactNode
(PascalCase prop name)
Composition slots, see §8
每个可视化组件都应支持以下属性:
属性类型说明
className
string
(可选)
classNames(...)
中始终最后合并
variant
ColorVariant
默认值
"primary"
(或本地子集)
size
SizeVariant
默认值
SizeVariants.medium
.base
disabled
boolean
添加
cursor-not-allowed opacity-50
样式
block
boolean
添加
w-full
样式
as
可区分的字面量联合类型参见§7 多态性
children
通过
PropsWithChildren<...>
传递
默认渲染插槽
<Slot>
ReactNode
(大驼峰属性名)
组合插槽,参见§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
...attributes
/
...inputProps
onto the underlying element so handlers,
aria-*
,
data-*
,
name
,
id
, etc., all just work.
始终扩展匹配的原生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
/
...inputProps
展开到底层元素上,使事件处理器、
aria-*
data-*
name
id
等属性自动生效。

Discriminated 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)

ref
作为常规属性(React 19)

ref
is a normal prop on function components, do not use
forwardRef
in new code
. Type it as
Ref<HTMLElement>
on the props interface:
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
forwardRef
atoms opportunistically when you touch them. Use callback refs (with optional cleanup return) when the parent needs to react to mount/unmount.

在函数组件中,
ref
是一个常规属性,新代码中禁止使用
forwardRef
。在属性接口中将其类型声明为
Ref<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>;
}
在修改现有原子组件时,可适时迁移
forwardRef
代码。当父组件需要响应挂载/卸载时,使用回调ref(可返回清理函数)。

6. Defaults &
Readonly

6. 默认值与
Readonly

  • Provide defaults in the destructuring signature:
    variant = "primary"
    ,
    size = SizeVariants.medium
    ,
    bold = false
    .
  • Wrap props in
    Readonly<PropsWithChildren<...>>
    when the component does not mutate its props, most components qualify.

  • 在解构签名中提供默认值:
    variant = "primary"
    size = SizeVariants.medium
    bold = false
  • 当组件不修改属性时,将属性包裹在
    Readonly<PropsWithChildren<...>>
    中,大多数组件都符合此条件。

7. Polymorphism via
as
Prop

7. 通过
as
属性实现多态性

When a component should render as different elements depending on context (link vs button vs section), use a discriminated
as
union
rather than
ElementType
:
tsx
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
href
only when
as="a"
and
onClick
typed for the right event when
as="button"
.
当组件需要根据上下文渲染为不同元素(链接、按钮、区块等)时,使用可区分的
as
联合类型
而非
ElementType
tsx
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"
时,消费者会自动补全
href
属性;当
as="button"
时,
onClick
会被正确类型化为对应事件。

Rules

规则

  • Limit
    as
    to a small set of literals (
    "button" | "a" | "section"
    ). Don't accept arbitrary
    ElementType
    at the prop boundary, that defeats type narrowing on the union.
  • Pick a default branch (typically
    "section"
    or
    "div"
    ) via
    as ?? "section"
    and assign to a
    Capitalized
    local typed as
    ElementType
    so JSX accepts it.
  • Native attribute interface must match the literal (
    "a"
    AnchorHTMLAttributes<HTMLAnchorElement>
    ).

  • as
    限制为少量字面量(
    "button" | "a" | "section"
    )。禁止在属性边界接受任意
    ElementType
    ,这会破坏联合类型的类型收窄能力。
  • 通过
    as ?? "section"
    选择默认分支(通常为
    "section"
    "div"
    ),并赋值给类型为
    ElementType
    的大驼峰本地变量,以便JSX可以接受它。
  • 原生属性接口必须与字面量匹配(
    "a"
    AnchorHTMLAttributes<HTMLAnchorElement>
    )。

8. Composition Slots

8. 组合插槽

For non-trivial components, expose named slot props of type
ReactNode
instead of cramming everything into
children
. Convention: slot prop names are PascalCase.
tsx
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>
对于复杂组件,暴露命名插槽属性(类型为
ReactNode
),而非将所有内容塞进
children
。约定:插槽属性名使用大驼峰
tsx
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
    ,
    TitleElement
    ), distinguishes them from primitive props.
  • children
    is reserved for the body / primary content.
  • For compound components (
    Tabs
    /
    Tab
    ,
    Accordion
    /
    AccordionItem
    ), share coordination state via a React Context scoped to the parent, read with
    useContext
    or React 19's
    use()
    . This is what Radix, Headless UI, React Aria, shadcn/ui, and the React docs all do. Reach for
    Children.map
    +
    cloneElement
    only as a last resort when you specifically need positional info (
    isFirst
    /
    isLast
    ) and the children are guaranteed to be direct, non-wrapped, non-conditional
    <Tab>
    elements.

  • 插槽属性名使用大驼峰(
    Title
    Actions
    TitleElement
    ),与基础属性区分开。
  • children
    保留用于主体/主要内容。
  • 对于复合组件(
    Tabs
    /
    Tab
    Accordion
    /
    AccordionItem
    ),通过父组件作用域内的React Context共享协调状态,使用
    useContext
    或React 19的
    use()
    读取状态。这是Radix、Headless UI、React Aria、shadcn/ui以及React文档推荐的方式。仅当明确需要位置信息(
    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 via
    FontAwesomeIconProps["icon"]
    and
    FontAwesomeIconProps["size"]
    :
    tsx
    interface ButtonIconTooltipProps {
      icon: FontAwesomeIconProps["icon"];
      size?: FontAwesomeIconProps["size"];
    }
  • Color via Tailwind
    className
    (
    "text-green-400"
    ,
    "text-red-400"
    ), not the FontAwesome
    color
    prop.
  • For loading states use the library's spin variant (e.g.
    <FontAwesomeIcon icon={faSpinner} spin />
    ). Don't combine
    spin
    with
    pulse
    ,
    pulse
    is FA4's legacy 8-step jump and produces a stuttery animation when paired with
    spin
    .
  • When an icon-only button needs accessible text, add
    <span className="sr-only">{label}</span>
    .
  • 选择一套默认图标集并统一使用。仅当默认图标集缺少合适的图标或替代集更符合语义时,才使用第二套图标集。
  • 禁止混合使用同一库的付费版和免费版,每个项目选择一个来源。
  • 通过
    FontAwesomeIconProps["icon"]
    FontAwesomeIconProps["size"]
    传递图标属性:
    tsx
    interface ButtonIconTooltipProps {
      icon: FontAwesomeIconProps["icon"];
      size?: FontAwesomeIconProps["size"];
    }
  • 通过Tailwind
    className
    设置颜色(
    "text-green-400"
    "text-red-400"
    ),禁止使用FontAwesome的
    color
    属性。
  • 加载状态使用库的spin变体(如
    <FontAwesomeIcon icon={faSpinner} spin />
    )。禁止将
    spin
    pulse
    组合使用,
    pulse
    是FA4的遗留8步跳跃动画,与
    spin
    搭配会产生卡顿效果。
  • 当仅含图标的按钮需要可访问文本时,添加
    <span className="sr-only">{label}</span>

9.2 Inline SVG illustrations

9.2 内联SVG插图

Brand illustrations and bespoke graphics live in
<components>/atoms/icons/
as React components. They:
  • Export a zero-prop function returning a single
    <svg>
    JSX literal.
  • 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/
中,需满足:
  • 导出一个无参数的函数,返回单个
    <svg>
    JSX字面量。
  • 通过尺寸/颜色参数化,它们是大型品牌SVG。
  • 从独立的导出文件导入,与常规原子组件区分开。
仅将其用于营销/插图场景。操作/状态图标始终使用图标库。

10. Styling Conventions

10. 样式约定

  • Tailwind only, no CSS Modules, no styled-components, no inline
    style
    except for genuinely dynamic values (e.g.
    objectFit
    , computed colors).
  • Pick a theme baseline. If the app is dark-mode-only, default to
    bg-gray-700/800/900
    ,
    text-white
    ,
    text-gray-300/400
    ,
    border-gray-600/700
    and don't add
    dark:
    prefixes. If the app supports both themes, define the baseline in light tokens and pair every surface/text token with a
    dark:
    counterpart.
  • Brand color, pick one of two Tailwind-native paths and stick to it:
    1. Use a built-in palette directly (
      indigo
      ,
      emerald
      ,
      blue
      ,
      slate
      , …). Simplest, matches the Tailwind UI / Tailwind Plus convention. Examples in this doc use
      indigo
      .
    2. Extend the theme with a project-named palette via
      theme.extend.colors.<name>
      (v3) or
      @theme { --color-<name>-50…950: … }
      (v4). Common names:
      primary
      ,
      accent
      , or the brand's own name. Avoid
      brand
      only if your design system already uses it for something else.
    Whichever path you pick, primary actions use the
    700
    /
    900
    shades:
    bg-<palette>-700
    /
    hover:bg-<palette>-900
    /
    focus:ring-<palette>-900
    . Don't mix paths within one project.
  • Focus states:
    focus:outline-none focus:ring-4 focus:ring-<color>
    .
  • Disabled state:
    cursor-not-allowed opacity-50
    .
  • Radius:
    rounded-lg
    for buttons/cards/dialogs,
    rounded-full
    for pills/avatars,
    rounded
    for tags.
  • Spacing scale anchors: padding
    px-5 py-2.5
    (medium control),
    p-2.5
    (input),
    p-4 md:p-6
    (modal section).
  • Typography: prefer a
    Text
    atom over hand-written
    text-2xl font-bold
    on raw
    <h2>
    in new code,
    <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原生方案之一并保持一致:
    1. 直接使用内置调色板
      indigo
      emerald
      blue
      slate
      等)。最简单,符合Tailwind UI/Tailwind Plus约定。本文档示例使用
      indigo
    2. 扩展主题,通过
      theme.extend.colors.<name>
      (v3)或
      @theme { --color-<name>-50…950: … }
      (v4)添加项目命名的调色板。常见名称:
      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
window
/
document
access at module load is harmless.
For SSR / SSG / RSC frameworks, any component depending on libraries that touch
window
/
document
at module load (
leaflet
/
react-leaflet
, MapLibre, certain charting libs, etc.) must be loaded as a client-only chunk:
tsx
// 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的项目可跳过本节,因为没有服务器渲染过程,在模块加载时访问
window
/
document
是安全的。
对于SSR/SSG/RSC框架,任何依赖在模块加载时访问
window
/
document
的库(
leaflet
/
react-leaflet
、MapLibre、某些图表库等)的组件,必须作为仅客户端代码块加载:
tsx
// 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 (
{ text: { en, es, ... } }
), 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,
next-intl
,
react-i18next
, etc.) and always fall back to the required language, never render
undefined
.
tsx
// 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 (
t("key")
) instead of per-record maps, this section does not apply, use the catalog API.

若项目为单语言,可跳过本节。
若项目将i18n内容存储为按语言划分的映射(
{ text: { en, es, ... } }
),选择一种语言作为必填默认语言,其他语言视为可选。在组件内部,从项目的i18n源(Redux、Context、
next-intl
react-i18next
等)读取当前语言,并始终回退到必填默认语言,禁止渲染
undefined
tsx
// 仅示例结构,需根据项目实际使用的i18n源进行配置。
const language = useCurrentLanguage();
const label = entity.text[language] ?? entity.text[DEFAULT_LANGUAGE];
若项目使用消息目录i18n(
t("key")
)而非每条记录的映射,本节不适用,使用目录API即可。

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
ref
as a normal prop, native attribute extension via
Omit<ButtonProps, "size" | "ref">
, FontAwesome icon prop forwarding, size dictionary, accessible fallback label, no manual memoization (compiler handles it), and
className
merged last.

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";

// 复用基础`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中
ref
作为常规属性使用、通过
Omit<ButtonProps, "size" | "ref">
扩展原生属性、FontAwesome图标属性传递、尺寸字典、可访问回退标签、无需手动 memoization(编译器处理)、
className
最后合并。

14. 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
forwardRef
is no longer needed for new components.
Applies 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,
use()
, accessibility, performance, TypeScript) is universal.
基于React 19编写组件。React编译器处理大多数memoization,新的ref/表单/过渡API替代了多个遗留模式,新组件不再需要
forwardRef
适用于任何React 19环境,包括Vite SPA、Next.js(应用路由或页面路由)、Remix、TanStack Start等。标记为*"App Router / RSC only"*的小节是框架特定的;纯Vite SPA项目可跳过。其他内容(编译器、refs、钩子、
use()
、可访问性、性能、TypeScript)是通用的。

Compiler-first mindset

编译器优先思维

Applies only if the React Compiler is actually enabled in the build (
babel-plugin-react-compiler
wired into Babel/SWC, or the Next.js
experimental.reactCompiler
flag, 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.
  • With the compiler enabled, components and hook outputs are auto-memoized. Stop hand-rolling
    useMemo
    /
    useCallback
    /
    React.memo
    for normal cases, write straightforward code and let the compiler optimize it.
  • Only reach for manual memoization when:
    • The compiler bails out (verified via the
      react-compiler-runtime
      warnings or React DevTools "✨" badge absence) and a profiler shows a real regression.
    • You're integrating with a non-compiled third-party that requires referential stability.
  • 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()
    /
    Date.now()
    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.
仅当构建中实际启用了React编译器时适用
babel-plugin-react-compiler
集成到Babel/SWC,或Next.js的
experimental.reactCompiler
标志等)。在未启用编译器的原生React 19项目中,以下规则会导致性能下降,在编译器启用前保留现有的memoization。
  • 启用编译器后,组件和钩子输出会自动memoize。**停止手动编写
    useMemo
    /
    useCallback
    /
    React.memo
    **处理常规情况,编写简洁代码即可,由编译器负责优化。
  • 仅在以下情况使用手动memoization:
    • 编译器退出优化(通过
      react-compiler-runtime
      警告或React DevTools中缺少"✨"徽章验证),且性能分析显示实际性能下降。
    • 与未编译的第三方库集成,且该库需要引用稳定性。
  • 在编译器下,作为子元素的内联对象/数组/函数属性是安全的,无需为了提升而扭曲代码。
  • 保持组件幂等(编译器假设组件是纯函数):渲染过程中不修改属性/状态,渲染体中不使用
    Math.random()
    /
    Date.now()
    ,事件处理器/副作用/refs之外无副作用。这些纯函数规则在React 19严格模式下同样重要,无论是否启用编译器。

ref
as a regular prop (drop
forwardRef
)

ref
作为常规属性(弃用
forwardRef

In React 19,
ref
is a normal prop on function components. Do not use
forwardRef
in new code.
Migrate atoms opportunistically.
tsx
// 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();
    }} />
  • useImperativeHandle
    is still valid when exposing a curated handle, but prefer plain
    ref
    forwarding.
在React 19中,
ref
是函数组件的常规属性。新代码中禁止使用
forwardRef
。适时迁移原子组件。
tsx
// 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
    useEffect
    as a last resort. Before reaching for it, ask:
    • 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
      await
      , etc.).
    • Is it shared UI state? → Use the project's existing store (Redux, Zustand, Context). Don't introduce a new mechanism for trivial cases.
  • Use
    useId()
    for label/
    htmlFor
    /
    aria-describedby
    /
    aria-labelledby
    pairings. Never roll counters or
    Math.random()
    ids.
  • Use
    useSyncExternalStore
    for non-React stores (
    matchMedia
    , custom event buses). Don't reimplement with
    useEffect
    +
    useState
    .
  • Use
    useDeferredValue
    /
    useTransition
    to keep input responsive when derivation/list filtering is expensive. Wrap the derivation, not the input.
  • useState
    setter accepts a function for derived updates:
    setCount((c) => c + 1)
    , required when the next value depends on the previous and updates may batch.
  • useEffect
    作为最后手段。在使用前,先问自己:
    • 是否可以在渲染过程中派生?(从属性/状态计算)
    • 是否可以在事件处理器中完成?
    • 是否是服务器状态?→ 使用项目的数据获取层(TanStack Query、SWR、RSC
      await
      等)。
    • 是否是共享UI状态?→ 使用项目现有的状态管理工具(Redux、Zustand、Context)。不要为琐碎的情况引入新机制。
  • 使用**
    useId()
    **处理标签/
    htmlFor
    /
    aria-describedby
    /
    aria-labelledby
    配对。禁止使用计数器或
    Math.random()
    生成id。
  • 使用**
    useSyncExternalStore
    **处理非React状态(
    matchMedia
    、自定义事件总线)。禁止使用
    useEffect
    +
    useState
    重新实现。
  • 使用**
    useDeferredValue
    /
    useTransition
    **在派生/列表过滤开销较大时保持输入响应性。包装派生逻辑,而非输入组件。
  • useState
    setter接受函数用于派生更新:
    setCount((c) => c + 1)
    ,当下一个值依赖于前一个值且更新可能批量处理时,必须使用此方式。

use()
, read promises and context conditionally

use()
,条件读取Promise和Context

React 19's
use()
reads a promise or context and can be called inside conditionals/loops (unlike other hooks).
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.
    use()
    is for promises produced by the server (RSC) or for one-shot client work that shouldn't go through a cache.
  • use(SomeContext)
    is the new way to read context conditionally;
    useContext
    still works for unconditional reads.
React 19的
use()
可以读取Promise或Context,且可以在条件/循环中调用(与其他钩子不同)。
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>
  • 优先使用项目的数据获取层处理客户端数据缓存。
    use()
    适用于服务器生成的Promise(RSC)或不应通过缓存处理的一次性客户端操作。
  • use(SomeContext)
    是条件读取Context的新方式;
    useContext
    仍适用于无条件读取。

Actions, transitions, and form hooks

动作、过渡和表单钩子

useActionState
/
useFormStatus
/
useOptimistic
are 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 any
(state, formData) => Promise<state>
reducer and get the same pending/state ergonomics.
useTransition
is fully framework-agnostic.
In a form, prefer the React 19 primitives over hand-rolled loading state.
  • useActionState
    , bind a
    (state, formData) => newState
    reducer to a
    <form>
    ; React handles pending state and (in RSC frameworks) progressive enhancement.
    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>
      );
    }
  • useFormStatus
    , read the parent
    <form>
    's pending state from a child without prop-drilling.
    tsx
    import { useFormStatus } from "react-dom";
    export function SubmitButton({ children }: PropsWithChildren) {
      const { pending } = useFormStatus();
      return <Button type="submit" disabled={pending}>{children}</Button>;
    }
  • useOptimistic
    , show optimistic UI while a mutation is in flight.
    tsx
    const [optimisticItems, addOptimistic] = useOptimistic(items, (state, next: Item) => [...state, next]);
  • useTransition
    , wrap async non-urgent updates:
    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
/
useOptimistic
可在任何React 19环境中使用
(包括Vite SPA),它们并不严格依赖RSC。在Server Actions中表现最佳,但在纯SPA中,您可以传递任何
(state, formData) => Promise<state>
reducer,获得相同的pending/状态管理体验。**
useTransition
**完全与框架无关。
在表单中,优先使用React 19原语,而非手动实现加载状态。
  • useActionState
    ,将
    (state, formData) => newState
    reducer绑定到
    <form>
    ;React处理pending状态,且在RSC框架中支持渐进式增强。
    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
    ,从子组件读取父
    <form>
    的pending状态,无需属性透传。
    tsx
    import { useFormStatus } from "react-dom";
    export function SubmitButton({ children }: PropsWithChildren) {
      const { pending } = useFormStatus();
      return <Button type="submit" disabled={pending}>{children}</Button>;
    }
  • useOptimistic
    ,在mutation进行时显示乐观UI。
    tsx
    const [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
"use client"
/
"use server"
directives don't apply.
  • Default App Router files are Server Components, no hooks, no event handlers, no browser APIs. They can
    await
    data and render directly.
  • Add
    "use client"
    only when the file uses hooks, event handlers, browser APIs, or imports a client-only lib.
  • Keep
    "use client"
    boundaries as leaves. Server Components can render Client Components, but Client Components can only render Server Components passed as
    children
    /slot props (
    <ClientShell>{serverNode}</ClientShell>
    ).
  • Server Actions (
    "use server"
    ) live in App Router action files only. Plain async functions in your data layer are not Server Actions, don't add the directive there.
若项目为纯SPA(Vite等)或仅使用页面路由,可完全跳过本节,所有组件默认都是客户端组件;
"use client"
/
"use server"
指令不适用。
  • 应用路由文件默认是服务器组件,不能使用钩子、事件处理器或浏览器API。它们可以
    await
    数据并直接渲染。
  • 仅当文件使用钩子、事件处理器、浏览器API或导入仅客户端库时,添加
    "use client"
  • 保持
    "use client"
    边界尽可能靠近叶子节点。服务器组件可以渲染客户端组件,但客户端组件只能渲染作为
    children
    /插槽属性传递的服务器组件(
    <ClientShell>{serverNode}</ClientShell>
    )。
  • Server Actions
    "use server"
    )仅存放在应用路由的action文件中。数据层中的普通异步函数不是Server Actions,不要为其添加该指令。

Suspense & errors

Suspense与错误处理

  • Wrap lazy-loaded chunks (
    React.lazy
    ,
    next/dynamic
    , framework equivalents),
    use()
    promises, and data-loading subtrees in
    <Suspense fallback={<SkeletonCard />} />
    , never bare spinners.
  • 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
    onUncaughtError
    /
    onCaughtError
    createRoot
    options can centralize reporting.
  • Don't throw promises manually, let
    use()
    , the lazy loader, or the data-fetching layer manage suspension.
  • 使用
    <Suspense fallback={<SkeletonCard />} />
    包裹懒加载代码块(
    React.lazy
    next/dynamic
    、框架等效方法)、
    use()
    Promise和数据加载子树,禁止使用裸加载指示器。
  • 使用错误边界包裹风险子树(地图、支付iframe、第三方小部件),并上报到项目的错误跟踪工具(Sentry等)。React 19的
    onUncaughtError
    /
    onCaughtError
    createRoot
    选项可集中处理上报。
  • 禁止手动抛出Promise,让
    use()
    、懒加载器或数据获取层管理暂停状态。

Document metadata, stylesheets, preloading

文档元数据、样式表、预加载

  • React 19 hoists
    <title>
    ,
    <meta>
    ,
    <link>
    , and
    <style>
    rendered inside components into
    <head>
    , use this for per-component metadata in any framework, including Vite SPAs.
  • For preconnects / preloads, use the
    react-dom
    resource APIs:
    import { preconnect, preload, prefetchDNS } from "react-dom"
    . Call from event handlers, or (in RSC frameworks) Server Components, not in render of Client Components.
  • React 19会将组件内渲染的
    <title>
    <meta>
    <link>
    <style>
    提升到
    <head>
    ,可用于任何框架(包括Vite SPA)中的组件级元数据。
  • 对于preconnect/preload,使用
    react-dom
    资源API:
    import { preconnect, preload, prefetchDNS } from "react-dom"
    。从事件处理器调用,或在RSC框架中从服务器组件调用,禁止在客户端组件的渲染过程中调用。

Accessibility (enforced)

可访问性(强制执行)

  • Every interactive element has an accessible name: visible label,
    aria-label
    , or
    <span className="sr-only">…</span>
    (icon-only buttons).
  • <button>
    always has explicit
    type="button" | "submit" | "reset"
    , default to
    "button"
    .
  • Modals use native
    <dialog>
    , keep
    aria-modal
    ,
    aria-label
    , and
    Escape
    handling.
  • Focus rings are mandatory:
    focus:outline-none focus:ring-4 focus:ring-<color>
    , never strip without replacement.
    :focus-visible
    is preferred over
    :focus
    for ring states.
  • Honor reduced motion: gate animations behind
    motion-safe:
    or
    prefers-reduced-motion
    when using motion libraries.
  • Color is never the sole signal, pair status colors with an icon or label.
  • Image components require
    alt
    ; for decorative images pass
    alt=""
    explicitly.
  • 每个交互元素都有可访问名称:可见标签、
    aria-label
    <span className="sr-only">…</span>
    (仅图标按钮)。
  • <button>
    始终显式设置
    type="button" | "submit" | "reset"
    ,默认值为
    "button"
  • 模态框使用原生
    <dialog>
    ,保留
    aria-modal
    aria-label
    和Escape按键处理。
  • 焦点环是必需的:
    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
    ,
    @unpic/react
    , etc.). On a plain Vite SPA without such a component, raw
    <img>
    is acceptable, pair it with
    loading="lazy"
    , explicit
    width
    /
    height
    to avoid CLS, and
    decoding="async"
    .
  • Lazy-load below-the-fold organisms and trees that touch
    window
    ,
    document
    , or heavy client-only libs. Use the framework's dynamic-import helper if it has one (
    next/dynamic
    ), otherwise plain
    React.lazy(() => import("./Heavy"))
    +
    <Suspense>
    works in any setup.
  • 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
    useDeferredValue
    or move to a worker.
  • Stable list keys, never array index for reorderable / filterable / paginated lists.
  • 对于内容图片,若框架提供优化的图片组件,使用该组件(
    next/image
    unpic
    @unpic/react
    等)。在无此类组件的纯Vite SPA中,可使用原始
    <img>
    ,搭配
    loading="lazy"
    、显式
    width
    /
    height
    避免CLS,以及
    decoding="async"
  • 懒加载视口下方的organisms组件和涉及
    window
    document
    或大型仅客户端库的组件树。若框架提供动态导入助手(如
    next/dynamic
    ),使用该助手;否则,纯
    React.lazy(() => import("./Heavy"))
    +
    <Suspense>
    适用于任何环境。
  • 命名导入图标(和其他可tree-shake的库),命名空间导入会破坏tree-shaking。
  • 依赖编译器进行memoization。在手动使用
    memo
    前先进行性能分析。
  • 避免在渲染过程中执行同步开销大的操作,使用
    useDeferredValue
    包裹或移到worker中。
  • 使用稳定的列表键,禁止对可重新排序/过滤/分页的列表使用数组索引作为键。

TypeScript ergonomics

TypeScript ergonomics

  • Recommended
    tsconfig
    :
    strict
    +
    noUncheckedIndexedAccess
    , array/dict access is
    T | undefined
    . Guard with
    ?? fallback
    or
    if
    .
  • Prefer
    ReactNode
    for children/slot props;
    ReactElement
    when you need to
    cloneElement
    .
  • PropsWithChildren<T>
    for children;
    Readonly<PropsWithChildren<T>>
    when the component does not mutate.
  • Type event handlers explicitly:
    MouseEventHandler<HTMLButtonElement>
    ,
    ChangeEventHandler<HTMLInputElement>
    . Never
    (e: any) => void
    .
  • Discriminated unions over
    Partial<T>
    when props travel together (see §5).
  • ref
    prop type is
    Ref<T>
    (which is
    RefObject<T> | RefCallback<T> | null
    ). Don't use the legacy
    LegacyRef
    /
    MutableRefObject
    .
  • 推荐
    tsconfig
    配置:
    strict
    +
    noUncheckedIndexedAccess
    ,数组/字典访问类型为
    T | undefined
    。使用
    ?? fallback
    if
    进行守卫。
  • 子元素/插槽属性优先使用
    ReactNode
    ;当需要
    cloneElement
    时使用
    ReactElement
  • 子元素使用
    PropsWithChildren<T>
    ;当组件不修改属性时使用
    Readonly<PropsWithChildren<T>>
  • 显式类型化事件处理器:
    MouseEventHandler<HTMLButtonElement>
    ChangeEventHandler<HTMLInputElement>
    。禁止使用
    (e: any) => void
  • 属性组合使用可区分联合类型而非
    Partial<T>
    (参见§5)。
  • 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. 反模式(禁止使用)

  • import clsx from "clsx"
    in a project that has the local
    classNames
    , use
    classNames
    from
    "utils"
    .
  • ❌ Pre-joined class strings:
    classNames("flex items-center")
    . Each class is its own arg.
  • ❌ Inventing local
    "sm" | "md" | "lg"
    size unions, use
    SizeVariants
    .
  • ❌ Inventing local color string unions, use
    ColorVariants
    (or narrow with
    Extract<>
    ).
  • as: ElementType
    , use a discriminated literal union.
  • ❌ Mixing icon libraries within a single project without a documented reason.
  • ❌ Setting icon color via
    color="red"
    , use Tailwind
    className
    .
  • ❌ Domain logic / data fetching inside
    atoms/
    or
    molecules/
    . Hooks belong in organisms or pages.
  • ❌ Adding
    dark:
    Tailwind classes in a dark-only project (or omitting them in a light/dark project).
  • ❌ Skipping the
    __tests__
    directory or barrel update when adding a new component.
  • ❌ Calling raw
    fetch()
    from a component, use the project's data-fetching layer.
  • ❌ Passing
    className
    before
    ...SizeDict[size]
    /
    ...ColorDict[variant]
    in
    classNames(...)
    , consumer override must come last.
  • forwardRef
    in new components,
    ref
    is a regular prop in React 19.
  • ❌ Hand-rolled
    useMemo
    /
    useCallback
    /
    React.memo
    without a profiler showing a regression, let the React Compiler do its job.
  • useEffect
    for derived values, event-handler work, or server data fetching.
  • ❌ Wrapping form-hook usages with
    "use client"
    in non-RSC frameworks (plain Vite SPA, Pages Router, etc.), the directive is meaningless there.
    useFormStatus
    /
    useActionState
    /
    useOptimistic
    work in any React 19 environment.
  • ❌ Adding
    "use server"
    to plain async data-layer functions, only RSC framework action files take that directive.
  • ❌ Class components, legacy lifecycle methods,
    defaultProps
    on function components,
    propTypes
    .
  • ❌ Reading
    window
    /
    document
    /
    localStorage
    at module scope or in render in any code path that runs on the server (SSR / SSG / RSC), guard with
    useEffect
    , callback ref, or a client-only dynamic import. (Pure SPAs without SSR are fine to read these freely, but
    useSyncExternalStore
    is still cleaner for reactive sources like
    matchMedia
    .)
  • ❌ Array index as
    key
    for reorderable / filterable lists.
  • ❌ Hand-rolled ids (
    Math.random()
    , counters) for label/aria associations, use
    useId()
    .
  • ❌ Namespace imports of icons, always named imports.
  • ❌ Stripping
    :focus
    /
    :focus-visible
    rings without an accessible replacement.
  • <button>
    without an explicit
    type
    .
  • ❌ Hidden interactive elements (
    <div onClick={...}>
    without
    role
    + keyboard handlers), use
    <button>
    .
  • ❌ Mutating props or state during render (breaks compiler assumptions and Strict Mode).
  • LegacyRef
    /
    MutableRefObject
    in prop types, use
    Ref<T>
    .

  • ❌ 在拥有本地
    classNames
    的项目中
    import clsx from "clsx"
    ,应使用
    "utils"
    中的
    classNames
  • ❌ 预先拼接类字符串:
    classNames("flex items-center")
    。每个类应作为单独的参数传入。
  • ❌ 创建本地
    "sm" | "md" | "lg"
    尺寸联合类型,应使用
    SizeVariants
  • ❌ 创建本地颜色字符串联合类型,应使用
    ColorVariants
    (或使用
    Extract<>
    筛选)。
  • ❌ 使用
    as: ElementType
    ,应使用可区分的字面量联合类型。
  • ❌ 在单个项目中混合使用图标库,除非有文档记录的理由。
  • ❌ 通过
    color="red"
    设置图标颜色,应使用Tailwind
    className
  • ❌ 在
    atoms/
    molecules/
    中包含业务逻辑/数据获取。钩子应放在organisms或页面中。
  • ❌ 在仅暗黑模式的项目中添加
    dark:
    Tailwind类(或在明暗双模式项目中省略)。
  • ❌ 添加新组件时跳过
    __tests__
    目录或导出文件更新。
  • ❌ 从组件中直接调用
    fetch()
    ,应使用项目的数据获取层。
  • ❌ 在
    classNames(...)
    className
    放在
    ...SizeDict[size]
    /
    ...ColorDict[variant]
    之前,消费者覆盖必须放在最后。
  • ❌ 在组件中使用
    forwardRef
    ,React 19中
    ref
    是常规属性。
  • ❌ 在性能分析未显示性能下降的情况下手动编写
    useMemo
    /
    useCallback
    /
    React.memo
    ,让React编译器处理。
  • ❌ 使用
    useEffect
    处理派生值、事件处理器工作或服务器数据获取。
  • ❌ 在非RSC框架(纯Vite SPA、页面路由等)中为表单钩子使用
    "use client"
    指令,该指令在这些环境中无意义。
    useFormStatus
    /
    useActionState
    /
    useOptimistic
    可在任何React 19环境中使用。
  • ❌ 为普通异步数据层函数添加
    "use server"
    ,仅RSC框架的action文件使用该指令。
  • ❌ 类组件、遗留生命周期方法、函数组件的
    defaultProps
    propTypes
  • ❌ 在任何服务器端执行的代码路径(SSR/SSG/RSC)中,在模块作用域或渲染过程中读取
    window
    /
    document
    /
    localStorage
    ,使用
    useEffect
    、回调ref或仅客户端动态导入进行守卫。(纯无SSR的SPA可自由读取,但对于
    matchMedia
    等响应式源,
    useSyncExternalStore
    仍然更简洁。)
  • ❌ 对可重新排序/过滤的列表使用数组索引作为
    key
  • ❌ 手动生成id(
    Math.random()
    、计数器)用于标签/aria关联,应使用
    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
:
  1. Filename matches the project's convention; prefer named
    export function Foo
    over default exports.
  2. Props interface extends matching native
    HTMLAttributes<...>
    ; include
    className?
    and
    variant
    /
    size
    if visual.
  3. Module-scope
    SizeDict
    /
    ColorDict
    if the component varies by enum.
  4. classNames(...)
    with each Tailwind class as own arg;
    className
    prop merged last.
  5. Spread
    ...rest
    props onto the underlying element.
  6. Default values in the destructuring signature.
  7. Use
    PropsWithChildren<...>
    (and
    Readonly<...>
    ) where appropriate.
  8. Add export line to the layer's
    index.ts
    barrel.
  9. If the layer/component has nearby tests, add a
    __tests__/Foo.test.tsx
    .
  10. For polymorphic components, use a discriminated
    as
    union.
  11. If on React 19, use
    ref
    as a regular prop (no
    forwardRef
    ).
添加
<components>/<layer>/Foo.tsx
时:
  1. 文件名符合项目约定;优先使用命名导出
    export function Foo
    而非默认导出。
  2. 属性接口扩展匹配的原生
    HTMLAttributes<...>
    ;若为可视化组件,包含
    className?
    variant
    /
    size
  3. 模块作用域内定义
    SizeDict
    /
    ColorDict
    (若组件按枚举变化)。
  4. 使用
    classNames(...)
    ,每个Tailwind类作为单独参数;
    className
    属性最后合并。
  5. ...rest
    属性展开到底层元素。
  6. 在解构签名中设置默认值。
  7. 适当使用
    PropsWithChildren<...>
    (和
    Readonly<...>
    )。
  8. 将导出添加到层级的
    index.ts
    导出文件中。
  9. 若层级/组件附近有测试文件,添加
    __tests__/Foo.test.tsx
  10. 多态组件使用可区分的
    as
    联合类型。
  11. 若使用React 19,将
    ref
    作为常规属性(无需
    forwardRef
    )。