首页
/ ECC 项目 React 18/19 工程化模式 Skill 全解:从 Hooks 纪律到 RSC、表单与状态决策

ECC 项目 React 18/19 工程化模式 Skill 全解:从 Hooks 纪律到 RSC、表单与状态决策

2026-09-06 18:18:38作者:贡沫苏Truman

本文面向在 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.mdhooks.mdpatterns.mdsecurity.mdtesting.md 五份规则文件支撑。它是 ECC 技能体系中「写代码时使用、做 Review 时对标」的 React 侧纲领,与 react-testingreact-performancefrontend-patternsaccessibility 等技能互相引用。

技能定位与激活场景(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.mdrules/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 新增 APIuse()(可内联解包 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:只传可序列化的 propschildren
  • 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: hiddenz-index 层叠上下文的内容,用 createPortal 渲染到 index.html 中的稳定 DOM 节点)。该规则文件还标注了 React 19 对 forwardRef 的演进:函数组件可直接把 ref 作为普通 prop 接收,forwardRef 不再是必需品;React 18 的存量代码库则仍需 forwardRef

性能:什么时候 React.memo 真的有帮助

Skill 关于记忆化给出三个同时成立才用的判据:

  1. 该组件重渲染频繁;
  2. 多次渲染之间 props 通常相同;
  3. 其渲染可测量的昂贵。

并且解释机理: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-virtualreact-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 技能/规则/命令体系中的完整协作关系:

因此实践路径通常是: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 的 useActionStateforwardRef 的必要性)均已在对应小节给出前提。如需深入单条规则,可直接阅读 rules/react/hooks.mdrules/react/patterns.md 的完整细则;仓库中面向其他语言的技能(如 rules/typescript/rules/common/ 等)也沿用了同等的 Skill/Rule 分层约定。

登录后查看全文
热门项目推荐
相关项目推荐