React 模式
用于构建健壮、可访问、高性能组件树的惯用 React 18/19 模式。
何时激活
- 编写或修改 React 函数组件、自定义 Hooks 或组件树
- 审查 JSX/TSX 文件
- 设计状态结构或组件组合
- 迁移类组件或大量使用旧的
forwardRef/useEffect的代码 - 在局部状态、提升的状态、Context 和外部存储之间做出选择
- 使用服务端组件/客户端组件(Next.js App Router、RSC)
- 使用 React 19 actions 或受控输入实现表单
- 使用 TanStack Query / SWR / RSC 进行数据获取
核心原则
1. 渲染是 Props 和 State 的纯函数
// 好:在渲染期间进行派生
function Cart({ items }: { items: CartItem[] }) {
const total = items.reduce((sum, i) => sum + i.price * i.qty, 0);
return <span>{formatMoney(total)}</span>;
}
// 坏:将派生状态单独存储
function Cart({ items }: { items: CartItem[] }) {
const [total, setTotal] = useState(0);
useEffect(() => {
setTotal(items.reduce((sum, i) => sum + i.price * i.qty, 0));
}, [items]);
return <span>{formatMoney(total)}</span>;
}
在 useEffect 中处理派生状态会增加一个渲染周期、可能导致同步问题,并且会模糊数据流。
2. 副作用在渲染之外
Effects(副作用)、突变、网络调用和订阅应位于事件处理器或 useEffect 中——永远不要放在渲染函数体内。
3. 组合优于继承
React 没有组件的继承模型。使用 children、render props 或组件 props 进行组合。
Hooks 规范
完整的规则集请参见 rules/react/hooks.md。要点如下:
- 仅在顶层调用,永远不要在条件语句中调用
- 清理每个订阅、定时器、事件监听器
- 当新状态依赖于旧状态时,使用函数式更新(
setX(prev => prev + 1)) - 默认立场:不要进行记忆化——仅在性能分析器或依赖链证明有必要时才添加
useMemo/useCallback - 仅当相同的 Hook 序列出现在 2 个以上的组件中时,才将其提取为自定义 Hook
状态位置决策树
仅由一个组件使用?
-> 在该组件内使用 useState
由父组件和少数后代使用?
-> 提升到最近的共同祖先
由不同分支使用且读取频率低(主题、认证、区域设置)?
-> React Context
在组件树中高频更新共享?
-> 外部存储(Zustand、Jotai、Redux Toolkit)
从服务端派生而来?
-> 服务端状态库(TanStack Query、SWR、RSC fetch)
大多数页面不需要 Context 或全局存储。在重复提升变得痛苦之前,抵制抽象化的冲动。
服务端 / 客户端组件(RSC)
// 服务端组件 - 默认,异步,永远不自行发送 JS
export default async function ProductPage({ params }: { params: { id: string } }) {
const product = await db.product.findUnique({ where: { id: params.id } });
if (!product) notFound();
return <ProductView product={product} />;
}
// 客户端组件 - 通过 "use client" 选择加入
"use client";
export function AddToCartButton({ productId }: { productId: string }) {
const [pending, startTransition] = useTransition();
return (
<button
disabled={pending}
onClick={() => startTransition(() => addToCart(productId))}
>
{pending ? "Adding..." : "Add to cart"}
</button>
);
}
边界:
- 服务端 -> 客户端:传递可序列化的 props 或
children - 客户端 -> 服务端:通过
<form action={...}>调用服务端 Actions,或从事件处理器中命令式调用 - 永远不要从客户端组件文件中
import服务端组件——改为通过children进行组合
Suspense + 错误边界
<ErrorBoundary fallback={<ErrorView />}>
<Suspense fallback={<UserSkeleton />}>
<UserDetail id={id} />
</Suspense>
</ErrorBoundary>
- 将 Suspense 边界放置在数据附近,而不是路由根节点——逐步揭示内容
- Error Boundary 仍然是类组件 API;使用
react-error-boundary获得更友好的 Hook 包装 - 边界捕获其子组件在渲染、生命周期和构造函数期间抛出的错误——不捕获事件处理器或异步代码中的错误
表单
React 19 表单 actions(新代码首选)
"use client";
import { useActionState } from "react";
const initial = { error: null as string | null };
async function updateUserAction(_prev: typeof initial, formData: FormData) {
"use server";
const parsed = UserSchema.safeParse(Object.fromEntries(formData));
if (!parsed.success) return { error: "Invalid input" };
await db.user.update({ where: { id: parsed.data.id }, data: parsed.data });
return { error: null };
}
export function UserForm() {
const [state, formAction, pending] = useActionState(updateUserAction, initial);
return (
<form action={formAction}>
<input name="name" required />
<button type="submit" disabled={pending}>Save</button>
{state.error && <p role="alert">{state.error}</p>}
</form>
);
}
受控输入
当值驱动其他 UI、每次按键都格式化或实现实时验证时,使用受控组件。
复杂表单
对于多步骤表单、动态字段数组或跨字段验证:使用库(React Hook Form、TanStack Form)。对于超过简单复杂度的表单,自行管理状态是一个维护陷阱。
数据获取决策矩阵
| 需求 | 工具 |
|---|---|
| 每个请求的数据(Next.js App Router) | RSC await fetch() |
| 客户端缓存 + 变更 + 失效 | TanStack Query |
| 轻量级客户端缓存 + 重新验证 | SWR |
| 实时订阅 | Server-Sent Events、WebSockets 或库的订阅 API |
| 一次性即发即忘 | 事件处理器中的 fetch() |
避免使用 useEffect + fetch 进行应用数据获取——会引发竞态条件、无缓存、无重试、无法集成 Suspense。
组合模式
通过 children 实现的插槽
<Layout>
<Header />
<Main>{content}</Main>
</Layout>
命名插槽
<Page header={<Nav />} sidebar={<Filters />}>
<Results />
</Page>
复合组件(通过 Context 共享状态)
<Tabs defaultValue="profile">
<Tabs.List>
<Tabs.Trigger value="profile">Profile</Tabs.Trigger>
<Tabs.Trigger value="settings">Settings</Tabs.Trigger>
</Tabs.List>
<Tabs.Panel value="profile"><Profile /></Tabs.Panel>
<Tabs.Panel value="settings"><Settings /></Tabs.Panel>
</Tabs>
Render prop / 函数作为子元素
当父组件需要向渲染输出传递参数时很有用:
<DataLoader id={id}>
{({ data, isLoading }) => isLoading ? <Spinner /> : <UserCard user={data} />}
</DataLoader>
现代替代方案:返回相同形状的 Hook(useData(id))——通常更清晰。
性能
何时 React.memo 才真正有帮助
仅在以下情况将组件包裹在 React.memo 中:
- 该组件频繁重新渲染
- 其 props 在渲染之间通常是相同的
- 其渲染开销可衡量地昂贵
React.memo 在每次渲染时都会增加一个相等性检查。如果大多数渲染的 props 都不同,那么这个检查就是纯粹的额外开销。
避免渲染级联
- 尽可能将状态下移而不是上提
- 拆分 Context:每个关注点一个 Context,这样
themeContext的改变不会导致认证消费者重新渲染 - 对于外部状态库,使用
useSyncExternalStore——对于安全的并发渲染是必需的
列表
- 提供稳定的
keyprop(数据库 ID,而不是数组索引) - 当可见项数超过 ~50 且行非平凡时,使用
@tanstack/react-virtual或react-window虚拟化长列表
无障碍优先的组合
- 在求助于
role属性之前,始终渲染语义化 HTML(<button>、<a>、<nav>、<main>) - 每个可交互元素必须可通过键盘访问
- 表单输入需要标签——
<label htmlFor>或如果由图标视觉标签,则使用aria-label - 在路由变化和模态框打开/关闭时管理焦点
- 在组件测试中运行
axe(参见 skills/react-testing) - 交叉参考:skills/accessibility/SKILL.md 涵盖了 WCAG 标准和模式库
路由
此技能与路由无关。上述模式适用于 React Router、TanStack Router、Next.js App Router、Remix Router。路由特定的模式(loaders、actions、嵌套布局)请遵循路由框架的文档——这些是建立在 React 核心之上的框架关注点。
不适用内容(指针章节)
- Next.js 细节:App Router 数据加载、Route Handlers、Middleware、Parallel Routes——分开关注,请使用 Next.js 文档
- React Native:平台特定的模式差异足够大,需要单独的
react-native-patterns技能(目前不存在) - Remix:Loader/action 约定与 RSC 有重叠,但请遵循 Remix 文档
相关资源
- 规则:rules/react/——编码风格、Hooks、模式、安全性、测试
- 技能:react-performance 用于 Vercel 派生的性能规则集,frontend-patterns 用于跨框架 UI 关注点,accessibility,angular-developer 用于框架比较
- Agents:
react-reviewer用于代码审查,react-build-resolver用于构建/打包器错误 - 命令:
/react-review、/react-build、/react-test
示例
用于防抖搜索的自定义 Hook
function useDebounce<T>(value: T, delay = 300): T {
const [debounced, setDebounced] = useState(value);
useEffect(() => {
const id = setTimeout(() => setDebounced(value), delay);
return () => clearTimeout(id);
}, [value, delay]);
return debounced;
}
function SearchBox() {
const [query, setQuery] = useState("");
const debounced = useDebounce(query, 300);
const { data } = useQuery({
queryKey: ["search", debounced],
queryFn: () => searchApi(debounced),
enabled: debounced.length > 0,
});
return (
<>
<input value={query} onChange={(e) => setQuery(e.target.value)} />
<Results items={data ?? []} />
</>
);
}
使用 React 19 useOptimistic 实现乐观 UI
"use client";
import { useOptimistic } from "react";
export function MessageList({ messages }: { messages: Message[] }) {
const [optimistic, addOptimistic] = useOptimistic(
messages,
(state, newMessage: Message) => [...state, newMessage],
);
async function send(formData: FormData) {
const text = String(formData.get("text"));
addOptimistic({ id: "pending", text, sender: "me" });
await saveMessage(text);
}
return (
<>
<ul>{optimistic.map((m) => <li key={m.id}>{m.text}</li>)}</ul>
<form action={send}>
<input name="text" />
<button type="submit">Send</button>
</form>
</>
);
}
拆分 Context 以避免渲染级联
// 两个 Context:一个很少变化,一个频繁变化
const ThemeContext = createContext<Theme>("light");
const NotificationsContext = createContext<Notification[]>([]);
// 一个只消费 ThemeContext 的组件不会在通知改变时重新渲染