From 8e415351a01ef55ebd815f4200180ca4f553be40 Mon Sep 17 00:00:00 2001 From: AlexisLeDain Date: Wed, 20 May 2026 09:53:27 +0200 Subject: [PATCH] feat(skills): add react-patterns, react-testing, react-performance MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three new skills under skills/ following the SKILL.md convention. - react-patterns: React 18/19 idioms — hooks discipline, state location decision tree, server/client component boundary, Suspense + error boundaries, form actions (React 19), data fetching matrix, composition recipes, accessibility-first. - react-testing: React Testing Library + Vitest/Jest, query priority order, userEvent, MSW network mocking, axe a11y assertions, RTL vs Playwright CT boundary, TDD workflow. - react-performance: 70-rule performance ruleset adapted from Vercel Labs react-best-practices (MIT) across 8 priority categories — waterfalls, bundle size, server-side, client fetch, re-render, rendering, JS micro, advanced patterns. Includes Lighthouse / Web Vitals mapping and attribution to upstream. Cross-links between the three skills and out to frontend-patterns, accessibility, e2e-testing, tdd-workflow. --- skills/react-patterns/SKILL.md | 341 ++++++++++++++++++ skills/react-performance/SKILL.md | 574 ++++++++++++++++++++++++++++++ skills/react-testing/SKILL.md | 414 +++++++++++++++++++++ 3 files changed, 1329 insertions(+) create mode 100644 skills/react-patterns/SKILL.md create mode 100644 skills/react-performance/SKILL.md create mode 100644 skills/react-testing/SKILL.md diff --git a/skills/react-patterns/SKILL.md b/skills/react-patterns/SKILL.md new file mode 100644 index 00000000..f1340c8c --- /dev/null +++ b/skills/react-patterns/SKILL.md @@ -0,0 +1,341 @@ +--- +name: react-patterns +description: React 18/19 patterns including hooks discipline, server/client component boundaries, Suspense + error boundaries, form actions, data fetching, state management decision trees, and accessibility-first composition. Use when writing or reviewing React components. +origin: ECC +--- + +# React Patterns + +Idiomatic React 18/19 patterns for building robust, accessible, performant component trees. + +## When to Activate + +- Writing or modifying React function components, custom hooks, or component trees +- Reviewing JSX/TSX files +- Designing state shape or component composition +- Migrating class components or older `forwardRef`/`useEffect`-heavy code +- Choosing between local state, lifted state, context, and external stores +- Working with Server Components / Client Components (Next.js App Router, RSC) +- Implementing forms with React 19 actions or controlled inputs +- Wiring data fetching with TanStack Query / SWR / RSC + +## Core Principles + +### 1. Render is a Pure Function of Props and State + +```tsx +// Good: derive during render +function Cart({ items }: { items: CartItem[] }) { + const total = items.reduce((sum, i) => sum + i.price * i.qty, 0); + return {formatMoney(total)}; +} + +// 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 {formatMoney(total)}; +} +``` + +Derived state in `useEffect` adds a render cycle, can desync, and obscures the data flow. + +### 2. Side Effects Outside Render + +Effects, mutations, network calls, and subscriptions live in event handlers or `useEffect` — never in the render body. + +### 3. Composition Over Inheritance + +React has no inheritance model for components. Compose with `children`, render props, or component props. + +## Hooks Discipline + +See [rules/react/hooks.md](../../rules/react/hooks.md) for the full ruleset. Highlights: + +- Top-level only, never conditional +- Cleanup every subscription, interval, listener +- Functional updater (`setX(prev => prev + 1)`) when new state depends on old +- Default position: do not memoize — add `useMemo`/`useCallback` only when a profiler or a dependency chain proves it matters +- Extract a custom hook only when the same hook sequence appears in 2+ components + +## State Location Decision Tree + +``` +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) +``` + +Most pages do not need context or a global store. Resist abstraction until duplicated lifting becomes painful. + +## Server / Client Components (RSC) + +```tsx +// 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 ; +} + +// Client Component - opt in with "use client" +"use client"; +export function AddToCartButton({ productId }: { productId: string }) { + const [pending, startTransition] = useTransition(); + return ( + + ); +} +``` + +Boundaries: + +- Server -> Client: pass serializable props or `children` +- Client -> Server: invoke Server Actions via `
` or imperatively from event handlers +- Never `import` a Server Component from a Client Component file — compose them via `children` instead + +## Suspense + Error Boundaries + +```tsx +}> + }> + + + +``` + +- Place Suspense boundaries close to the data, not at the route root — progressively reveal content +- Error Boundary remains a class API; use `react-error-boundary` for a hook-friendly wrapper +- A boundary catches errors thrown during render, lifecycle, and constructors of its children — NOT in event handlers or async code + +## Forms + +### React 19 form actions (preferred for new code) + +```tsx +"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 ( + + + + {state.error &&

{state.error}

} +
+ ); +} +``` + +### Controlled inputs + +Use controlled when the value drives other UI, formats on every keystroke, or implements real-time validation. + +### Complex forms + +For multi-step forms, dynamic field arrays, or cross-field validation: use a library (React Hook Form, TanStack Form). Roll-your-own state management for forms past trivial complexity is a maintenance trap. + +## Data Fetching Decision Matrix + +| Need | Tool | +|---|---| +| Per-request data in Next.js App Router | RSC `await fetch()` | +| Client-side cache + mutations + invalidation | TanStack Query | +| Lightweight client cache + revalidation | SWR | +| Real-time subscriptions | Server-Sent Events, WebSockets, or the lib's subscription API | +| One-off fire-and-forget | `fetch()` in an event handler | + +Avoid `useEffect` + `fetch` for application data — race conditions, no cache, no retry, no Suspense integration. + +## Composition Recipes + +### Slot via `children` + +```tsx + +
+
{content}
+ +``` + +### Named slots + +```tsx +} sidebar={}> + + +``` + +### Compound components (shared state via Context) + +```tsx + + + Profile + Settings + + + + +``` + +### Render prop / function-as-child + +Useful when the parent needs to pass parameters to the rendered output: + +```tsx + + {({ data, isLoading }) => isLoading ? : } + +``` + +Modern alternative: a hook (`useData(id)`) returning the same shape — usually cleaner. + +## Performance + +### When `React.memo` Actually Helps + +Wrap a component in `React.memo` only when: + +1. It re-renders frequently +2. Its props are usually the same between renders +3. Its render is measurably expensive + +`React.memo` adds an equality check on every render. If props differ on most renders, the check is pure overhead. + +### Avoiding Render Cascades + +- Lift state down rather than up where possible +- Split context: one context per concern, so a change to `themeContext` does not re-render auth consumers +- Use `useSyncExternalStore` for external state libraries — required for safe concurrent rendering + +### Lists + +- Provide stable `key` props (database id, not array index) +- Virtualize long lists with `@tanstack/react-virtual` or `react-window` once visible item count exceeds ~50 with non-trivial rows + +## Accessibility-First Composition + +- Always render semantic HTML (` + + + ); +} +``` + +### Splitting context to avoid render cascades + +```tsx +// Two contexts: one rarely changes, one frequently +const ThemeContext = createContext("light"); +const NotificationsContext = createContext([]); + +// A component that only consumes ThemeContext does NOT re-render when notifications change +``` diff --git a/skills/react-performance/SKILL.md b/skills/react-performance/SKILL.md new file mode 100644 index 00000000..91ccf8f7 --- /dev/null +++ b/skills/react-performance/SKILL.md @@ -0,0 +1,574 @@ +--- +name: react-performance +description: React and Next.js performance optimization patterns adapted from Vercel Engineering's React Best Practices (https://github.com/vercel-labs/agent-skills). Organizes 70+ rules across 8 priority categories — waterfalls, bundle size, server-side, client fetching, re-render, rendering, JS micro-perf, advanced. Use when writing, reviewing, or refactoring React/Next.js code for performance. +origin: ECC +--- + +# React Performance + +Performance optimization patterns for React 18/19 and Next.js, adapted from [Vercel Labs `react-best-practices`](https://github.com/vercel-labs/agent-skills/tree/main/skills/react-best-practices) (MIT, v1.0.0). This skill organizes rules by priority and provides decision-tree guidance for active code review and refactoring. + +## When to Activate + +- Writing or reviewing React/Next.js code for performance +- Diagnosing slow page loads, slow interactions, or high CPU on the client +- Auditing bundle size or Lighthouse Core Web Vitals regressions +- Removing waterfalls in Server Components / API routes +- Reducing client-side re-renders +- Optimizing long lists, animations, or hydration +- Auditing optimization choices in PRs touching `app/`, `pages/`, `components/`, or data layers + +## Priority Index + +| Priority | Category | Prefix | When it matters | +|---|---|---|---| +| 1 — CRITICAL | Eliminating Waterfalls | `async-` | Anytime `await` is followed by independent `await` | +| 2 — CRITICAL | Bundle Size Optimization | `bundle-` | First-load JS, route-level imports, third-party libs | +| 3 — HIGH | Server-Side Performance | `server-` | RSC, Server Actions, API routes, SSR | +| 4 — MEDIUM-HIGH | Client-Side Data Fetching | `client-` | SWR / TanStack Query / raw `fetch` in hooks | +| 5 — MEDIUM | Re-render Optimization | `rerender-` | High-frequency state updates, parent-child fan-out | +| 6 — MEDIUM | Rendering Performance | `rendering-` | Long lists, animations, hydration | +| 7 — LOW-MEDIUM | JavaScript Performance | `js-` | Hot loops, frequent allocations | +| 8 — LOW | Advanced Patterns | `advanced-` | Effect-event integration, stable refs | + +## 1. Eliminating Waterfalls (CRITICAL) + +> "Waterfalls are the #1 performance killer" — every sequential `await` adds full network latency. + +### Cheap conditions before await + +Check sync conditions (props, env, hardcoded flags) before awaiting remote data. + +```ts +// INCORRECT +async function Page({ id }: { id: string }) { + const flag = await getFlag("show-page"); + if (!flag || !id) return null; + const data = await getData(id); + // ... +} + +// CORRECT — short-circuit on cheap sync condition first +async function Page({ id }: { id: string }) { + if (!id) return null; + const flag = await getFlag("show-page"); + if (!flag) return null; + const data = await getData(id); +} +``` + +### Defer awaits until used + +Move `await` into the branch that uses it. + +```ts +// INCORRECT — awaits before deciding it needs the data +const user = await getUser(id); +if (mode === "guest") return renderGuest(); +return renderUser(user); + +// CORRECT +if (mode === "guest") return renderGuest(); +const user = await getUser(id); +return renderUser(user); +``` + +### Promise.all for independent work + +```ts +// INCORRECT — sequential +const user = await getUser(id); +const posts = await getPosts(id); +const followers = await getFollowers(id); + +// CORRECT — parallel +const [user, posts, followers] = await Promise.all([ + getUser(id), + getPosts(id), + getFollowers(id), +]); +``` + +### Partial dependencies — start early, await late + +```ts +// CORRECT — kick off all promises, await only when each result is needed +const userP = getUser(id); +const postsP = getPosts(id); +const profile = await getProfile(id); +if (profile.private) return null; +const [user, posts] = await Promise.all([userP, postsP]); +``` + +### Suspense for streaming + +Push `` boundaries close to the data so the page paints what it can while slower sub-trees stream in. The trade-off: layout shift when content arrives — reserve space (skeleton or `min-height`). + +### Server Components: parallel through composition + +```tsx +// INCORRECT — sibling awaits run sequentially inside one component +export default async function Page() { + const user = await getUser(); + const cart = await getCart(); + return ; +} + +// CORRECT — split into children, React runs them in parallel +export default async function Page() { + return ( + + + + + ); +} +``` + +## 2. Bundle Size Optimization (CRITICAL) + +### Direct imports, not barrels + +Barrel `index.ts` files force the bundler to walk the entire module graph even when tree-shaking removes most of it. Direct imports save 200-800ms of first-load JS in many real-world apps. + +```ts +// INCORRECT +import { Button, Card, Modal } from "@/components"; + +// CORRECT +import { Button } from "@/components/Button"; +import { Card } from "@/components/Card"; +import { Modal } from "@/components/Modal"; +``` + +Next.js 13.5+ has [Optimize Package Imports](https://nextjs.org/docs/app/api-reference/next-config-js/optimizePackageImports) that automates this for listed packages — use it; manual direct imports still required for non-listed libs. + +### Statically analyzable paths + +```ts +// INCORRECT — defeats bundler/trace analysis +const mod = await import(`./pages/${name}`); + +// CORRECT — explicit per branch +const mod = name === "home" ? await import("./pages/home") : await import("./pages/about"); +``` + +### Dynamic imports for heavy components + +```tsx +import dynamic from "next/dynamic"; + +const HeavyChart = dynamic(() => import("./HeavyChart"), { + loading: () => , + ssr: false, // when client-only +}); +``` + +### Defer third-party scripts + +Load analytics, logging, support widgets AFTER hydration. Use `next/script` with `strategy="afterInteractive"` (default) or `"lazyOnload"`. + +### Conditional module loading + +```tsx +if (user.role === "admin") { + const { AdminPanel } = await import("./admin/AdminPanel"); + // ... +} +``` + +### Preload on hover/focus + +Trigger `` or `import()` on hover so the bundle is in cache by the time the user clicks. + +## 3. Server-Side Performance (HIGH) + +### Authenticate Server Actions like API routes + +Every `"use server"` function is a public endpoint. Authenticate AND authorize inside the action — never rely on the calling Client Component's gating. + +```ts +"use server"; +export async function deleteUser(formData: FormData) { + const session = await getSession(); + if (!session?.user) throw new Error("Unauthorized"); + const targetId = String(formData.get("id")); + if (session.user.role !== "admin" && session.user.id !== targetId) { + throw new Error("Forbidden"); + } + await db.user.delete({ where: { id: targetId } }); +} +``` + +### `React.cache()` for per-request deduplication + +```ts +import { cache } from "react"; + +export const getUser = cache(async (id: string) => { + return db.user.findUnique({ where: { id } }); +}); +``` + +`React.cache` dedupes within a single request. Calling `getUser("1")` from three Server Components in the same render = one DB query. + +### LRU cache for cross-request data + +For data that does NOT change per request (config, lookup tables), cache outside React with an LRU cache or `unstable_cache`. + +### Avoid duplicate serialization in RSC props + +When a Server Component renders the same data into multiple Client Components, the data is serialized once per consumer. Lift the Client Component up and pass children. + +### Hoist static I/O to module scope + +```ts +// CORRECT — runs once at module load +const fontData = readFileSync(fontPath); + +export async function Page() { + return ; +} +``` + +### No mutable module-level state in RSC/SSR + +Module state on the server is shared across all requests — a race condition between users. Use request-scoped storage (`headers()`, `cookies()`, async context) instead. + +### Minimize data passed to Client Components + +Only serialize what the Client needs. Strip fields, paginate, project columns at the DB layer. + +### Parallelize nested fetches with Promise.all per item + +```ts +const users = await getUsers(); +const enriched = await Promise.all( + users.map(async (u) => ({ ...u, posts: await getPostsFor(u.id) })), +); +``` + +### Use `after()` for non-blocking work + +Next.js 15 `after()` runs work after the response is sent — logging, cache warming, analytics. + +```ts +import { after } from "next/server"; +export async function GET() { + const data = await getData(); + after(() => logAnalytics(data)); + return Response.json(data); +} +``` + +## 4. Client-Side Data Fetching (MEDIUM-HIGH) + +### SWR / TanStack Query for deduplication + +Multiple components calling `useUser(id)` should share one network request and one cache entry. Use SWR or TanStack Query — never roll your own `useEffect` + `fetch` for shared data. + +### Deduplicate global event listeners + +```tsx +// INCORRECT — every component adds its own +useEffect(() => { + window.addEventListener("scroll", handler); + return () => window.removeEventListener("scroll", handler); +}, []); + +// CORRECT — single shared listener via a hook + global subject +const useScroll = createScrollHook(); // singleton subject under the hood +``` + +### Passive listeners for scroll + +```ts +window.addEventListener("scroll", handler, { passive: true }); +``` + +Improves scrolling smoothness; the listener cannot `preventDefault()`. + +### localStorage: version + minimize + +- Always store a `version` field; bump on schema change and migrate or discard old data +- Keep payloads small — `localStorage` is synchronous and blocks main thread + +## 5. Re-render Optimization (MEDIUM) + +### Don't subscribe to state used only in callbacks + +```tsx +// INCORRECT — re-renders every time count changes +const count = useStore((s) => s.count); +const handler = () => doSomething(count); + +// CORRECT — read once on call +const handler = () => { + const count = useStore.getState().count; + doSomething(count); +}; +``` + +### Extract expensive work into memoized components + +```tsx +// CORRECT — child re-renders only when `items` changes +const Heavy = memo(function Heavy({ items }: { items: Item[] }) { + return ; +}); +``` + +### Hoist default non-primitive props + +```tsx +// INCORRECT — new array each render breaks memo + + +// CORRECT +const EMPTY: Item[] = []; + +``` + +### Primitive dependencies in effects + +```tsx +// INCORRECT — new object identity every render +useEffect(() => {}, [{ id, name }]); + +// CORRECT — primitives +useEffect(() => {}, [id, name]); +``` + +### Subscribe to derived booleans, not raw values + +```tsx +// INCORRECT — re-renders for any cart change +const cart = useStore((s) => s.cart); +const hasItems = cart.length > 0; + +// CORRECT — re-renders only when emptiness flips +const hasItems = useStore((s) => s.cart.length > 0); +``` + +### Derive during render, never via `useEffect` + +```tsx +// INCORRECT +const [full, setFull] = useState(""); +useEffect(() => setFull(`${first} ${last}`), [first, last]); + +// CORRECT +const full = `${first} ${last}`; +``` + +### Functional `setState` for stable callbacks + +```tsx +// CORRECT +const increment = useCallback(() => setCount((c) => c + 1), []); +``` + +### Lazy state initializer for expensive values + +```tsx +const [tree] = useState(() => parseTree(largeInput)); +``` + +### Avoid memo for simple primitives + +`useMemo(() => x + 1, [x])` is overhead. Memo earns its keep on object identity and expensive computation. + +### Split hooks with independent deps + +```tsx +// INCORRECT — both selectors re-run if either source changes +const { a, b } = useSomething(source1, source2); + +// CORRECT +const a = useA(source1); +const b = useB(source2); +``` + +### Move interaction logic into event handlers + +Event handlers run only on the user action — `useEffect` re-runs whenever deps change. + +### `startTransition` for non-urgent updates + +```tsx +const [pending, startTransition] = useTransition(); +startTransition(() => setFilters(newFilters)); +``` + +### `useDeferredValue` for expensive renders + +```tsx +const deferredQuery = useDeferredValue(query); +const results = useMemo(() => expensiveSearch(deferredQuery), [deferredQuery]); +``` + +### `useRef` for transient frequent values + +For values that change often but should not trigger re-render (timestamps, last-key, accumulators). + +### Don't define components inside components + +```tsx +// INCORRECT — Inner is a new component on every Outer render +function Outer() { + const Inner = () => ; + return ; +} +``` + +Each render makes a new `Inner` type, defeating reconciliation and unmounting children. + +## 6. Rendering Performance (MEDIUM) + +### Animate the wrapper, not the SVG + +Transforming a `
` wrapper around an SVG is GPU-accelerated; transforming the SVG itself triggers paint. + +### `content-visibility: auto` for long lists + +```css +.row { content-visibility: auto; contain-intrinsic-size: auto 80px; } +``` + +Browser skips offscreen rendering — major win for lists with hundreds of rows. + +### Hoist static JSX + +```tsx +const STATIC_HEADER =

Title

; +function Page() { + return <>{STATIC_HEADER}; +} +``` + +### SVG: reduce coordinate precision + +`d="M10.123456,20.654321"` → `d="M10.12,20.65"`. Each digit costs bytes; the visual difference is sub-pixel. + +### Hydration no-flicker via inline script + +For values needed before hydration (theme, locale), inline a `