ECC 项目 React 18/19 工程化模式 Skill 全解:从 Hooks 纪律到 RSC、表单与状态决策
本文面向在 React 18/19 生态中编写与评审组件代码的开发者,系统讲解 ECC(The agent harness performance optimization system)仓库内
react-patterns技能文档沉淀的工程化模式体系:渲染纯函数原则、Hooks 使用纪律、状态归属决策树、Server/Client Components(RSC)边界、Suspense 与错误边界、React 19 表单 Actions、数据获取决策矩阵以及无障碍优先的组合模式。读完本文,你将获得一套可直接落地到组件编写与 Code Review 场景的判别标准与代码骨架,并能在 ECC 的技能体系中追溯每一条规则背后的完整细则。
react-patterns 技能文档的核心路径为 .kiro/skills/react-patterns/SKILL.md(同名内容亦维护于 skills/react-patterns/SKILL.md),其配套细则由 rules/react/ 目录下的 coding-style.md、hooks.md、patterns.md、security.md、testing.md 五份规则文件支撑。它是 ECC 技能体系中「写代码时使用、做 Review 时对标」的 React 侧纲领,与 react-testing、react-performance、frontend-patterns、accessibility 等技能互相引用。
技能定位与激活场景(When to Activate)
该 Skill 面向「编写或评审 React 组件」这一动作设计。按文档定义,以下场景应当主动激活它:
- 编写或修改 React 函数组件、自定义 Hooks、组件树;
- Review JSX/TSX 文件(对应 ECC 的
react-reviewer评审代理与/react-review命令); - 设计状态形态(state shape)或组件组合方式;
- 迁移 class 组件、或重构大量使用
forwardRef/useEffect的旧代码; - 在本地 state、状态提升、Context 与外部 store 之间做技术选型;
- 使用 Server Components / Client Components(Next.js App Router、RSC)进行开发;
- 用 React 19 Actions 或受控输入实现表单;
- 接入 TanStack Query / SWR / RSC 的数据获取。
从仓库结构看,这套模式与 rules/react/hooks.md、rules/react/patterns.md 形成「Skill 提供可读模式样例、Rule 提供可执行红线」的分工:Skill 面向 Agent 学习与检索,Rule 通过 paths 字段(如 **/*.tsx、**/*.jsx)声明自动生效的代码路径。当 Agent 在带有 hooks 规则钩子的上下文中工作时,eslint-plugin-react-hooks 的配置与依赖数组规范会被强制或建议执行。
核心原则一:Render 是 Props 与 State 的纯函数
文档开篇提出 React 组件的第一条工程原则:渲染是 props 与 state 的纯函数。最典型(也最常见的反例)是把「可以由现有状态推导出的值」塞进 useState,再通过 useEffect 同步——这既多渲染一轮,又可能造成状态失同步,还会遮蔽数据流。
// Good: derive during render
function Cart({ items }: { items: CartItem[] }) {
const total = items.reduce((sum, i) => sum + i.price * i.qty, 0);
return <span>{formatMoney(total)}</span>;
}
// Bad: derived state stored separately
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>;
}
这条原则在 rules/react/hooks.md 中被展开成「useEffect 何时不该用」的清单:派生状态、为渲染而做的数据变换、响应 prop 变化的 state 重置(应当用父级的 key 或直接由 props 推导)、通知父级状态变化(应在事件处理器中调用回调)、初始化应用级单例(应在模块顶层或 main.tsx 中调用)。
// WRONG: effect for derived state
const [fullName, setFullName] = useState("");
useEffect(() => {
setFullName(`${first} ${last}`);
}, [first, last]);
// CORRECT: derive during render
const fullName = `${first} ${last}`;
换言之,useEffect 只用于「与外部系统同步」(订阅、浏览器 API、第三方库),而不是「在 React 内部搬运数据」。
核心原则二与三:副作用离开渲染体、组合优于继承
第二原则:副作用(effect、mutation、网络调用、订阅)只能存在于事件处理器或 useEffect 中,绝不能出现在渲染函数体内——渲染体只应做纯计算。第三原则:React 没有组件的继承模型,组合有三种形式——通过 children 插槽、通过 render props、通过组件 props 传入。
关于第三原则,rules/react/patterns.md 还补充了第四条组合形式「传组件类型作为插件点」(renderItem={UserRow}),并明确「永远不要通过继承 class 组件来特化行为」。
Hooks 使用纪律
Skill 将 Hooks 细则指向 rules/react/hooks.md,自己则保留要点如下:
- 只在顶层调用,绝不条件调用——不能放在循环、条件、嵌套函数或提前 return 之后;
- 清理一切订阅、定时器、监听器——漏掉清理意味着依赖变更时的竞态、卸载时的内存泄漏;
- 新状态依赖旧状态时使用函数式更新(
setX(prev => prev + 1)),尤其避免在异步或批量更新上下文中使用setCount(count + 1); - 默认不记忆化——只有当 profiler 或依赖链证明
useMemo/useCallback有意义时才添加; - 仅当同一组 hook 序列在 2 个及以上组件出现时才抽取自定义 hook——单调用者的逻辑应内联。
规则文件给出了更严格的表述与可执行配置。例如错误的条件 hook 与正确的写法:
// WRONG: conditional hook
function Foo({ enabled }: { enabled: boolean }) {
if (enabled) {
const [x, setX] = useState(0); // rule violation
}
}
// CORRECT: hook unconditional, condition inside
function Foo({ enabled }: { enabled: boolean }) {
const [x, setX] = useState(0);
if (!enabled) return null;
return <span>{x}</span>;
}
依赖数组方面:rules/react/hooks.md 要求 react-hooks/exhaustive-deps 开启、effect/回调内引用的每个响应式值都必须入数组;若依赖数组失控增长,说明该 effect 职责过多,应当拆分。若函数需要作为另一个 hook 的依赖或传给记忆化的子组件,才用 useCallback 稳定身份。
cleanup 的典型样板(可中止的请求与定时器):
useEffect(() => {
const controller = new AbortController();
fetch(url, { signal: controller.signal }).then(handleResponse);
return () => controller.abort();
}, [url]);
useEffect(() => {
const id = setInterval(tick, 1000);
return () => clearInterval(id);
}, []);
规则还专门点了三个进阶主题:
useSyncExternalStore:任何外部 store(浏览器 API、第三方状态库、自定义事件发射器)都该用它订阅,这是在并发渲染下安全使用外部状态的官方通道;Skill 在性能一节也强调「外部状态库必须走useSyncExternalStore」。内置的navigator.onLine示例即为经典用例。- React 19 新增 API:
use()(可内联解包 Promise 与 Context,也是唯一可在条件中使用的 hook)、useFormStatus()/useFormState()(或useActionState)、useOptimistic()、useTransition()。项目以 React 19+ 为目标时应优先使用它们,而非手写等价物。 - Stale Closure 陷阱:异步处理器与定时器捕获的是其创建时那一帧渲染的值。三种修法——用
setState的函数式更新、把变化值放进 effect 依赖数组并重建处理器、或从一个保持同步的 ref 读取。
对应 ESLint 配置基准:
{
"rules": {
"react-hooks/rules-of-hooks": "error",
"react-hooks/exhaustive-deps": "warn"
}
}
规则要求:新代码中把 exhaustive-deps 的告警当作 CI 错误处理;对必须的静默需要注释说明理由。
状态归属决策树(State Location Decision Tree)
Skill 给出了可以按顺序回答的决策树:
Used by one component?
-> useState inside it
Used by parent + a few descendants?
-> lift to nearest common ancestor
Used across distant branches AND low-frequency reads (theme, auth, locale)?
-> React Context
High-frequency updates shared across the tree?
-> external store (Zustand, Jotai, Redux Toolkit)
Derived from a server?
-> server-state library (TanStack Query, SWR, RSC fetch)
关键告诫有两条:其一,「大多数页面并不需要 Context 或全局 store」——在状态提升确实变得痛苦之前,抵制抽象冲动;其二,Context 被误用于高频变化的值时,会导致每次更新都让所有消费者重渲染(rules 中同样警告了这一点)。server 派生的数据不属于应用状态,应交给 server-state 库,而不是塞进全局 store。
Server / Client Components(RSC)边界
围绕 Next.js App Router 与 RSC,Skill 先给出两类组件的对照示例:
// Server Component - default, async, never ships JS for itself
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} />;
}
// Client Component - opt in with "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>
);
}
跨边界通信的三条规则:
- Server → Client:只传可序列化的 props或
children; - Client → Server:通过
<form action={...}>或从事件处理器命令式调用 Server Actions; - 绝不要从 Client Component 文件
import一个 Server Component——应通过children组合注入。
在 rules/react/patterns.md 中这些规则还有安全层面的延伸:绝不要在 Client Component 文件中引入 "server-only" 包(数据库客户端、密钥等),DB 访问要包在 Server Component 或 Server Action 内;敏感模块应标记 import "server-only",让打包器在客户端文件误引时直接报错。
Suspense 与错误边界
Skill 给出的标准配对写法是「错误边界包在 Suspense 外层」:
<ErrorBoundary fallback={<ErrorView />}>
<Suspense fallback={<UserSkeleton />}>
<UserDetail id={id} />
</Suspense>
</ErrorBoundary>
三条实践要点:
- Suspense 边界应放在靠近数据处,而不是路由根部——多个更窄的边界能让内容渐进式呈现;
- 错误边界至今仍是 class API——React 19 尚无函数式等价物,可用
react-error-boundary这类库提供 hook 友好的包装; - 错误边界只捕获其子树在渲染、生命周期与构造函数中抛出的错误——不捕获事件处理器与异步代码中的错误。
rules/react/patterns.md 对此的措辞更严格:「每个 Suspense 边界上方都必须有一个错误边界」,二者配对才能同时处理加载与出错两种状态。
表单:React 19 Actions 优先
Skill 明确「新代码优先使用 React 19 表单 Actions」,并给出端到端示例——useActionState 绑定 action、用 Zod schema 在 server 侧解析 FormData、用 pending 禁用按钮、用 role="alert" 暴露错误:
"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>
);
}
需要留意的一个版本差异:React 19+ 使用 useActionState;若项目停留在 React 18,则改用 react-dom 中的 useFormState——这是文档中明确标注的兼容性说明。
何时使用受控输入:当 value 驱动其他 UI、需要逐键格式化、或需要实时校验时。而面对多步表单、动态字段数组、跨字段校验等复杂场景,Skill 的建议非常直接:使用现成表单库(React Hook Form、TanStack Form),为超过简单复杂度的表单手写状态管理是一个维护陷阱。受控输入的经典写法为 const [email, setEmail] = useState(""); <input value={email} onChange={(e) => setEmail(e.target.value)} />。
数据获取决策矩阵
Skill 用一张表收束「该用哪种数据获取方案」的问题:
| Need | Tool |
|---|---|
| 在 Next.js App Router 中按请求取数 | RSC await fetch() |
| 客户端缓存 + 变更 + 失效 | TanStack Query |
| 轻量客户端缓存 + 再验证 | SWR |
| 实时订阅 | Server-Sent Events、WebSockets,或所用库的订阅 API |
| 一次性 fire-and-forget | 事件处理器中的 fetch() |
文档对反模式的批评毫不含糊:为应用数据使用 useEffect + fetch 需要避免——它会带来竞态条件、没有缓存、没有重试、无法接入 Suspense。规则文件的补充是:只要可用真正的缓存库,就不要在 useEffect 中取数,因为那些库负责去重、缓存失效、错误重试与 Suspense 集成。
组合模式 Recipes
Skill 依次给出四种组合形态的骨架:
Slot via children:
<Layout>
<Header />
<Main>{content}</Main>
</Layout>
具名插槽:
<Page header={<Nav />} sidebar={<Filters />}>
<Results />
</Page>
通过 Context 共享状态的 Compound Components(复合组件):
<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 / 函数作为子元素: 当父组件需要向渲染输出传参时有用;但 Skill 也给出更现代的替代——返回同样形态的 hook(useData(id)),通常更干净:
<DataLoader id={id}>
{({ data, isLoading }) => isLoading ? <Spinner /> : <UserCard user={data} />}
</DataLoader>
rules/react/patterns.md 在此基础上补充了两个与「架构」相关的模式:Container / Presentational 拆分(容器组件拥有数据获取、状态与副作用;展示组件只收 props 渲染,不碰服务调用与除本地 UI 状态外的 hooks);以及 Portals(模态框、tooltip、toast 容器等需要逃逸父级 overflow: hidden 或 z-index 层叠上下文的内容,用 createPortal 渲染到 index.html 中的稳定 DOM 节点)。该规则文件还标注了 React 19 对 forwardRef 的演进:函数组件可直接把 ref 作为普通 prop 接收,forwardRef 不再是必需品;React 18 的存量代码库则仍需 forwardRef。
性能:什么时候 React.memo 真的有帮助
Skill 关于记忆化给出三个同时成立才用的判据:
- 该组件重渲染频繁;
- 多次渲染之间 props 通常相同;
- 其渲染可测量的昂贵。
并且解释机理:React.memo 每次渲染都多做一次相等性检查;若 props 大多数时候都不同,这个检查就是纯开销。这与 Hooks 纪律中「默认不记忆化」一脉相承,也与 react-performance 技能形成对照。
避免渲染级联(render cascade)的三个手段:
- 尽量把 state 下移而非上移(lift state down);
- 按关注点拆分 Context——一个 Context 只负责一个关注点,这样
themeContext的更新不会让 auth 的消费组件重渲染。文档的示例是双 Context 方案:
// Two contexts: one rarely changes, one frequently
const ThemeContext = createContext<Theme>("light");
const NotificationsContext = createContext<Notification[]>([]);
// A component that only consumes ThemeContext does NOT re-render when notifications change
- 外部状态库使用
useSyncExternalStore,这是并发渲染下安全性的要求。
列表渲染:
- 使用稳定的
key(数据库 id,而不是数组下标);rules 中补充:key 只需在兄弟节点间唯一而非全局唯一,且重排列表若用 index 做 key 会让子组件状态串行到错误的行; - 可见行数超过约 50 行且每行渲染不便宜时,用
@tanstack/react-virtual或react-window虚拟化长列表。
无障碍优先的组合(Accessibility-First Composition)
Skill 的可访问性主张「先在语义结构上做到位,再谈 ARIA」:
- 永远优先使用语义化 HTML(
<button>、<a>、<nav>、<main>),之后才考虑role属性; - 每个可交互元素都必须能通过键盘到达;
- 表单输入要有 label——
<label htmlFor>,或当视觉上以图标标注时使用aria-label; - 路由切换与模态框开合时要管理焦点;
- 在组件测试中跑
axe(详见 skills/react-testing/SKILL.md); - 交叉引用:WCAG 准则与模式库的完整覆盖见 skills/accessibility/SKILL.md。
这也与 ECC 的 a11y-architect 代理等角色的关注面形成呼应——技能层面给出的「键盘可达 + 语义 HTML + label 完整」是每一轮 React 评审都必须检查的底线项。
路由:框架无关的立场
该技能刻意保持路由无关:上述模式对 React Router、TanStack Router、Next.js App Router、Remix Router 都适用。Router 专属的模式(loaders、actions、嵌套布局)属于「叠在 React 核心之上的框架关注点」,应遵循各路由器的官方文档。
边界范围(Out of Scope)与「指针式」定位
Skill 明确自己不越界处理以下领域,仅做指针指引:
- Next.js 具体机制:App Router 数据加载、Route Handlers、Middleware、Parallel Routes——属于独立关注点,遵循 Next.js 官方文档;
- React Native:平台专属模式差异足够大,需要独立的
react-native-patterns技能(仓库内尚未建立;与之相对,仓库已存在面向各语言/框架的独立 track 惯例); - Remix:Loader/Action 约定与 RSC 有重叠,但遵循 Remix 官方文档。
这种「克制」正是 ECC 技能库的组织哲学——每份 Skill 拥有明确聚焦边界,超出边界的内容只做指针,避免文档无限膨胀。
完整示例组合
文档在 Examples 一节给出三个可直接落地的完整示例。
1) 自定义防抖搜索 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 ?? []} />
</>
);
}
这个例子本身即是「数据获取决策矩阵 + 自定义 hook 抽取 + cleanup 纪律」的综合演示:输入防抖依赖 TanStack Query 管理,enabled 控制查询是否发起,而不是在 useEffect 里裸 fetch。
2) React 19 useOptimistic 乐观更新:
"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>
</>
);
}
注意此处 form action={send} 中 send 是定义在客户端组件内的 async 函数,配合 useOptimistic 让 UI 在 server action 挂起期间先呈现乐观结果。
3) 拆分 Context 避免渲染级联(见上文「性能」一节的双 Context 示例)。
在 ECC 中的协同工作流
react-patterns 并非孤立的文档。从 Skill 的 Related 段落与仓库目录结构可梳理出它在 ECC 技能/规则/命令体系中的完整协作关系:
- 规则(Rules):rules/react/ 下的
coding-style、hooks、patterns、security、testing提供自动校验红线,其中 hooks 规则通过paths字段(**/*.tsx、**/*.jsx、**/hooks/**/*.ts等)定位生效文件; - 技能(Skills):react-performance(源自 Vercel 的性能规则集)、frontend-patterns(跨框架 UI 关注点)、accessibility、react-testing、angular-developer(框架对比);
- 代理(Agents):
react-reviewer用于代码评审(对应仓库顶层 agents/react-reviewer.md); - 命令(Commands):
/react-review、/react-build、/react-test(分别对应 commands/react-review.md、commands/react-build.md、commands/react-test.md)。
因此实践路径通常是:Agent 编写组件时以本文模式为默认写法 → hooks 规则给出 lint 红线 → 提交评审时由 react-reviewer + /react-review 按同一套标准检查 → react-testing 技能规定在组件测试中运行 axe 与断言行为。
总结:从模式清单到评审清单
纵观全文档,react-patterns 的核心贡献是把 React 18/19 的最佳实践收敛成一组可判别的标准:渲染内派生而非 effect 同步、副作用隔离在渲染体外、hooks 顶层调用且 cleanup 齐全、状态归属自底向上按决策树逐层放权、RSC 边界只传可序列化数据且 Server Component 只能经 children 进入客户端、表单默认走 React 19 Actions、应用数据取数交由专业缓存库、记忆化以「可测量收益」为准绳、无障碍从语义 HTML 起步。评审组件时逐条对照这组清单,比泛泛的「代码质量」讨论要可操作得多。
需要说明的是,本文所引示例与规则以当前仓库为准,涉及版本差异(如 React 18 的 useFormState vs React 19 的 useActionState、forwardRef 的必要性)均已在对应小节给出前提。如需深入单条规则,可直接阅读 rules/react/hooks.md 与 rules/react/patterns.md 的完整细则;仓库中面向其他语言的技能(如 rules/typescript/、rules/common/ 等)也沿用了同等的 Skill/Rule 分层约定。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0624
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00