From d1e104f043af1a6cedf4729f15dd032b5a0c4215 Mon Sep 17 00:00:00 2001 From: "JH.Lee" Date: Tue, 17 Mar 2026 19:08:19 +0900 Subject: [PATCH 1/2] feat(agents): add React code review skill for agent-based guidance Add comprehensive React code review guidelines as an Agent Skill following the vercel-labs/agent-skills standard. Includes 23 rules across 4 categories: Rules of React, Re-render Optimization, Rendering Performance, and Advanced Patterns. Skill is available in .agents/skills/review-react and symlinked to .claude/skills for Claude Code auto-discovery. Co-Authored-By: Claude Haiku 4.5 --- .agents/skills/review-react/SKILL.md | 80 ++++++++++++ .../skills/review-react/rules/_sections.md | 26 ++++ .../skills/review-react/rules/_template.md | 28 ++++ .../rules/advanced-event-handler-refs.md | 49 +++++++ .../review-react/rules/advanced-init-once.md | 68 ++++++++++ .../review-react/rules/react-rules-calling.md | 66 ++++++++++ .../review-react/rules/react-rules-hooks.md | 91 +++++++++++++ .../review-react/rules/react-rules-purity.md | 121 ++++++++++++++++++ .../review-react/rules/rendering-activity.md | 51 ++++++++ .../rules/rendering-conditional-render.md | 41 ++++++ .../rules/rendering-content-visibility.md | 55 ++++++++ .../review-react/rules/rendering-hoist-jsx.md | 58 +++++++++ .../rules/rendering-usetransition-loading.md | 60 +++++++++ .../rules/rerender-defer-reads.md | 63 +++++++++ .../rules/rerender-dependencies.md | 56 ++++++++ .../rules/rerender-derived-state-no-effect.md | 42 ++++++ .../rules/rerender-derived-state.md | 36 ++++++ .../rules/rerender-functional-setstate.md | 44 +++++++ .../rules/rerender-lazy-state-init.md | 35 +++++ .../rules/rerender-memo-with-default-value.md | 43 +++++++ .../review-react/rules/rerender-memo.md | 51 ++++++++ .../rules/rerender-move-effect-to-event.md | 57 +++++++++ .../rules/rerender-no-inline-components.md | 43 +++++++ .../rerender-simple-expression-in-memo.md | 34 +++++ .../rules/rerender-transitions.md | 60 +++++++++ .../rerender-use-ref-transient-values.md | 55 ++++++++ .claude/skills/review-react | 1 + AGENTS.md | 4 + 28 files changed, 1418 insertions(+) create mode 100644 .agents/skills/review-react/SKILL.md create mode 100644 .agents/skills/review-react/rules/_sections.md create mode 100644 .agents/skills/review-react/rules/_template.md create mode 100644 .agents/skills/review-react/rules/advanced-event-handler-refs.md create mode 100644 .agents/skills/review-react/rules/advanced-init-once.md create mode 100644 .agents/skills/review-react/rules/react-rules-calling.md create mode 100644 .agents/skills/review-react/rules/react-rules-hooks.md create mode 100644 .agents/skills/review-react/rules/react-rules-purity.md create mode 100644 .agents/skills/review-react/rules/rendering-activity.md create mode 100644 .agents/skills/review-react/rules/rendering-conditional-render.md create mode 100644 .agents/skills/review-react/rules/rendering-content-visibility.md create mode 100644 .agents/skills/review-react/rules/rendering-hoist-jsx.md create mode 100644 .agents/skills/review-react/rules/rendering-usetransition-loading.md create mode 100644 .agents/skills/review-react/rules/rerender-defer-reads.md create mode 100644 .agents/skills/review-react/rules/rerender-dependencies.md create mode 100644 .agents/skills/review-react/rules/rerender-derived-state-no-effect.md create mode 100644 .agents/skills/review-react/rules/rerender-derived-state.md create mode 100644 .agents/skills/review-react/rules/rerender-functional-setstate.md create mode 100644 .agents/skills/review-react/rules/rerender-lazy-state-init.md create mode 100644 .agents/skills/review-react/rules/rerender-memo-with-default-value.md create mode 100644 .agents/skills/review-react/rules/rerender-memo.md create mode 100644 .agents/skills/review-react/rules/rerender-move-effect-to-event.md create mode 100644 .agents/skills/review-react/rules/rerender-no-inline-components.md create mode 100644 .agents/skills/review-react/rules/rerender-simple-expression-in-memo.md create mode 100644 .agents/skills/review-react/rules/rerender-transitions.md create mode 100644 .agents/skills/review-react/rules/rerender-use-ref-transient-values.md create mode 120000 .claude/skills/review-react diff --git a/.agents/skills/review-react/SKILL.md b/.agents/skills/review-react/SKILL.md new file mode 100644 index 000000000..33b6885db --- /dev/null +++ b/.agents/skills/review-react/SKILL.md @@ -0,0 +1,80 @@ +--- +name: review-react +description: > + React code review guidelines covering Rules of React, re-render optimization, + rendering performance, and advanced patterns. Activates when writing, reviewing, + or refactoring React components, hooks, or state management code. +--- + +# React Code Review Guidelines + +Performance optimization and correctness guide for React applications. Contains 23 rules across 4 categories, prioritized by impact. + +## When to Apply + +Reference these guidelines when: +- Writing or reviewing React components and hooks +- Optimizing re-render performance +- Refactoring state management or effect logic +- Reviewing pull requests that touch React code + +## Rule Categories by Priority + +| Priority | Category | Impact | Prefix | Rules | +|----------|----------|--------|--------|-------| +| 1 | Rules of React | CRITICAL | `react-rules-` | 3 | +| 2 | Re-render Optimization | MEDIUM | `rerender-` | 13 | +| 3 | Rendering Performance | MEDIUM | `rendering-` | 5 | +| 4 | Advanced Patterns | LOW | `advanced-` | 2 | + +## Quick Reference + +### 1. Rules of React (CRITICAL) + +- `react-rules-purity` - Components and Hooks must be pure; no side effects during render +- `react-rules-hooks` - Only call Hooks at the top level and from React functions +- `react-rules-calling` - Never call components as functions or pass Hooks as values + +### 2. Re-render Optimization (MEDIUM) + +- `rerender-no-inline-components` - Never define components inside other components +- `rerender-derived-state-no-effect` - Derive state during render, not in effects +- `rerender-memo` - Extract memoized child components to avoid re-renders +- `rerender-memo-with-default-value` - Hoist default non-primitive props outside memo +- `rerender-simple-expression-in-memo` - Don't useMemo for simple primitive expressions +- `rerender-defer-reads` - Don't subscribe to state only used in callbacks +- `rerender-dependencies` - Use primitive values in effect dependencies +- `rerender-derived-state` - Subscribe to derived booleans, not raw objects +- `rerender-functional-setstate` - Use functional setState for stable callbacks +- `rerender-lazy-state-init` - Pass initializer function to useState for expensive values +- `rerender-move-effect-to-event` - Move interaction logic from effects to event handlers +- `rerender-transitions` - Use startTransition for non-urgent state updates +- `rerender-use-ref-transient-values` - Use refs for frequently-changing transient values + +### 3. Rendering Performance (MEDIUM) + +- `rendering-hoist-jsx` - Hoist static JSX outside component functions +- `rendering-conditional-render` - Use ternary operator instead of && for conditional rendering +- `rendering-usetransition-loading` - Prefer useTransition over manual loading state +- `rendering-content-visibility` - Use CSS content-visibility: auto for long lists +- `rendering-activity` - Use Activity component for preserving hidden UI state + +### 4. Advanced Patterns (LOW) + +- `advanced-event-handler-refs` - Store latest event handlers in refs for stable callbacks +- `advanced-init-once` - Initialize app-level singletons once, not per mount + +## How to Use + +Read individual rule files for detailed explanations and code examples: + +``` +rules/react-rules-purity.md +rules/rerender-no-inline-components.md +``` + +Each rule file contains: +- Brief explanation of why it matters +- Incorrect code example +- Correct code example +- Reference links diff --git a/.agents/skills/review-react/rules/_sections.md b/.agents/skills/review-react/rules/_sections.md new file mode 100644 index 000000000..f709e9be7 --- /dev/null +++ b/.agents/skills/review-react/rules/_sections.md @@ -0,0 +1,26 @@ +# Sections + +This file defines all sections, their ordering, impact levels, and descriptions. +The section ID (in parentheses) is the filename prefix used to group rules. + +--- + +## 1. Rules of React (react-rules) + +**Impact:** CRITICAL +**Description:** Fundamental rules from react.dev that ensure correctness. Components must be pure, Hooks must follow call rules, and components must not be called as functions. + +## 2. Re-render Optimization (rerender) + +**Impact:** MEDIUM +**Description:** Patterns to minimize unnecessary re-renders: proper memoization, derived state, functional setState, and effect dependency management. + +## 3. Rendering Performance (rendering) + +**Impact:** MEDIUM +**Description:** Techniques to optimize what and how React renders: hoisting static JSX, conditional rendering patterns, content-visibility, and transitions. + +## 4. Advanced Patterns (advanced) + +**Impact:** LOW +**Description:** Specialized techniques for edge cases: storing handlers in refs for stable callbacks and one-time initialization patterns. diff --git a/.agents/skills/review-react/rules/_template.md b/.agents/skills/review-react/rules/_template.md new file mode 100644 index 000000000..1e9e70703 --- /dev/null +++ b/.agents/skills/review-react/rules/_template.md @@ -0,0 +1,28 @@ +--- +title: Rule Title Here +impact: MEDIUM +impactDescription: Optional description of impact (e.g., "20-50% improvement") +tags: tag1, tag2 +--- + +## Rule Title Here + +**Impact: MEDIUM (optional impact description)** + +Brief explanation of the rule and why it matters. This should be clear and concise, explaining the performance implications. + +**Incorrect (description of what's wrong):** + +```typescript +// Bad code example here +const bad = example() +``` + +**Correct (description of what's right):** + +```typescript +// Good code example here +const good = example() +``` + +Reference: [Link to documentation or resource](https://example.com) diff --git a/.agents/skills/review-react/rules/advanced-event-handler-refs.md b/.agents/skills/review-react/rules/advanced-event-handler-refs.md new file mode 100644 index 000000000..518662985 --- /dev/null +++ b/.agents/skills/review-react/rules/advanced-event-handler-refs.md @@ -0,0 +1,49 @@ +--- +title: Store Event Handlers in Refs for Stable Callbacks +impact: LOW +impactDescription: provides stable function identity without stale closures +tags: advanced, refs, event-handlers, useRef, stable-callback +--- + +## Store Event Handlers in Refs for Stable Callbacks + +**Impact: LOW (provides stable function identity without stale closures)** + +When you need a callback that always calls the latest version of a function without changing identity, store the handler in a ref. This avoids both stale closures and unnecessary re-renders from changing callback props. + +**Incorrect (callback changes identity, causing child re-renders):** + +```tsx +function Chat({ roomId }) { + const [message, setMessage] = useState('') + + const handleSend = useCallback(() => { + sendMessage(roomId, message) + }, [roomId, message]) // Changes on every keystroke + + return +} +``` + +**Correct (ref-based stable callback):** + +```tsx +function Chat({ roomId }) { + const [message, setMessage] = useState('') + + const handleSendRef = useRef(() => {}) + handleSendRef.current = () => { + sendMessage(roomId, message) + } + + const handleSend = useCallback(() => { + handleSendRef.current() + }, []) + + return +} +``` + +This is the pattern behind `useEffectEvent` (experimental). When `useEffectEvent` stabilizes, prefer it over manual ref management. + +Reference: [Separating Events from Effects](https://react.dev/learn/separating-events-from-effects) diff --git a/.agents/skills/review-react/rules/advanced-init-once.md b/.agents/skills/review-react/rules/advanced-init-once.md new file mode 100644 index 000000000..a53c42240 --- /dev/null +++ b/.agents/skills/review-react/rules/advanced-init-once.md @@ -0,0 +1,68 @@ +--- +title: Initialize App-Level Singletons Once +impact: LOW +impactDescription: prevents duplicate initialization in Strict Mode and re-mounts +tags: advanced, initialization, singleton, module-scope +--- + +## Initialize App-Level Singletons Once + +**Impact: LOW (prevents duplicate initialization in Strict Mode and re-mounts)** + +App-level setup (analytics, error tracking, SDK initialization) should run once per app load, not per component mount. Strict Mode double-invokes effects, causing duplicate initialization. + +**Incorrect (initialization in effect runs twice in Strict Mode):** + +```tsx +function App() { + useEffect(() => { + analytics.init('key') // Runs twice in Strict Mode + errorTracker.setup() // May cause duplicate event listeners + }, []) + + return +} +``` + +**Correct (module-level initialization):** + +```tsx +// app-init.ts +let initialized = false + +export function initApp() { + if (initialized) return + initialized = true + analytics.init('key') + errorTracker.setup() +} +``` + +```tsx +// App.tsx +import { initApp } from './app-init' + +initApp() // Runs once at module evaluation + +function App() { + return +} +``` + +**Also correct (top-level flag guard):** + +```tsx +let didInit = false + +function App() { + useEffect(() => { + if (didInit) return + didInit = true + analytics.init('key') + }, []) + + return +} +``` + +Reference: [How to handle the Effect firing twice in development](https://react.dev/learn/synchronizing-with-effects#how-to-handle-the-effect-firing-twice-in-development) diff --git a/.agents/skills/review-react/rules/react-rules-calling.md b/.agents/skills/review-react/rules/react-rules-calling.md new file mode 100644 index 000000000..444318d6e --- /dev/null +++ b/.agents/skills/review-react/rules/react-rules-calling.md @@ -0,0 +1,66 @@ +--- +title: React Calls Components and Hooks +impact: CRITICAL +impactDescription: breaks React's rendering model and optimization +tags: react-rules, components, hooks, calling-convention +--- + +## React Calls Components and Hooks + +**Impact: CRITICAL (breaks React's rendering model and optimization)** + +React must control when components render and hooks execute. Calling them directly bypasses reconciliation, state management, and optimization. + +### Rule 1: Never call component functions directly + +**Incorrect (calling component as function):** + +```tsx +function Parent() { + // This bypasses React's rendering, no proper lifecycle or state isolation + return
{Profile({ name: 'Alice' })}
+} +``` + +**Correct (use JSX):** + +```tsx +function Parent() { + return
+} +``` + +Calling a component as a function makes React treat it as part of the parent's render. This means: +- No separate fiber node for reconciliation +- State and effects are tied to the parent +- Keys and refs don't work as expected + +### Rule 2: Never pass Hooks as regular values + +**Incorrect (passing hook as prop):** + +```tsx +function ChatRoom({ useStatus }) { + const status = useStatus() // Hook passed as value + return

{status}

+} + + +``` + +**Correct (call hook directly, pass result as prop):** + +```tsx +function ChatRoom({ status }) { + return

{status}

+} + +function ChatRoomWrapper() { + const status = useOnlineStatus() + return +} +``` + +Passing hooks as values makes them opaque to React's static analysis, breaks the Rules of Hooks, and prevents the compiler from optimizing correctly. + +Reference: [React calls Components and Hooks](https://react.dev/reference/rules/react-calls-components-and-hooks) diff --git a/.agents/skills/review-react/rules/react-rules-hooks.md b/.agents/skills/review-react/rules/react-rules-hooks.md new file mode 100644 index 000000000..6d98c5c7a --- /dev/null +++ b/.agents/skills/review-react/rules/react-rules-hooks.md @@ -0,0 +1,91 @@ +--- +title: Rules of Hooks +impact: CRITICAL +impactDescription: violating causes runtime errors and broken state +tags: react-rules, hooks, top-level +--- + +## Rules of Hooks + +**Impact: CRITICAL (violating causes runtime errors and broken state)** + +Hooks rely on a stable call order. Calling them conditionally or in loops breaks React's ability to track state correctly. + +### Rule 1: Only call Hooks at the top level + +**Incorrect (hook inside condition):** + +```tsx +function Form({ showName }) { + if (showName) { + const [name, setName] = useState('') // Conditional hook call + } + const [email, setEmail] = useState('') + return setEmail(e.target.value)} /> +} +``` + +**Correct (always call hooks, conditionally use values):** + +```tsx +function Form({ showName }) { + const [name, setName] = useState('') + const [email, setEmail] = useState('') + return ( + <> + {showName && setName(e.target.value)} />} + setEmail(e.target.value)} /> + + ) +} +``` + +**Incorrect (hook inside loop):** + +```tsx +function Filters({ filters }) { + const values = [] + for (const f of filters) { + values.push(useState(f.default)) // Hook in loop + } + return <>{/* ... */} +} +``` + +**Correct (extract to child component or use single state):** + +```tsx +function Filters({ filters }) { + const [values, setValues] = useState(() => + Object.fromEntries(filters.map(f => [f.id, f.default])) + ) + return <>{/* ... */} +} +``` + +### Rule 2: Only call Hooks from React functions + +**Incorrect (hook in regular function):** + +```tsx +function getUser() { + const [user, setUser] = useState(null) // Not a React component + return user +} +``` + +**Correct (hook in component or custom hook):** + +```tsx +function useUser() { + const [user, setUser] = useState(null) + return user +} + +function Profile() { + const user = useUser() + return

{user?.name}

+} +``` + +Reference: [Rules of Hooks](https://react.dev/reference/rules/rules-of-hooks) diff --git a/.agents/skills/review-react/rules/react-rules-purity.md b/.agents/skills/review-react/rules/react-rules-purity.md new file mode 100644 index 000000000..9f16fd5b9 --- /dev/null +++ b/.agents/skills/review-react/rules/react-rules-purity.md @@ -0,0 +1,121 @@ +--- +title: Components and Hooks Must Be Pure +impact: CRITICAL +impactDescription: prevents bugs from non-deterministic rendering +tags: react-rules, purity, side-effects, immutability +--- + +## Components and Hooks Must Be Pure + +**Impact: CRITICAL (prevents bugs from non-deterministic rendering)** + +React assumes components are pure functions: same inputs, same output. Side effects during render cause bugs that are hard to reproduce, especially with concurrent features and Strict Mode. + +### Rule 1: Components must be idempotent + +**Incorrect (mutating external variable during render):** + +```tsx +let count = 0 + +function Counter() { + count++ // Side effect during render + return

{count}

+} +``` + +**Correct (use state for mutable values):** + +```tsx +function Counter() { + const [count, setCount] = useState(0) + return

{count}

+} +``` + +### Rule 2: Side effects must run outside of render + +**Incorrect (side effect in render):** + +```tsx +function ProductList({ items }) { + analytics.track('viewed', items.length) // Runs on every render + return
    {items.map(i =>
  • {i.name}
  • )}
+} +``` + +**Correct (side effect in effect or event handler):** + +```tsx +function ProductList({ items }) { + useEffect(() => { + analytics.track('viewed', items.length) + }, [items.length]) + return
    {items.map(i =>
  • {i.name}
  • )}
+} +``` + +### Rule 3: Props and state are immutable + +**Incorrect (mutating props):** + +```tsx +function List({ items }) { + items.push({ id: 'last' }) // Mutating prop + return
    {items.map(i =>
  • {i.name}
  • )}
+} +``` + +**Correct (create new values):** + +```tsx +function List({ items }) { + const allItems = [...items, { id: 'last', name: 'Last' }] + return
    {allItems.map(i =>
  • {i.name}
  • )}
+} +``` + +### Rule 4: Return values and arguments to Hooks are immutable + +**Incorrect (mutating hook return value):** + +```tsx +function useItems() { + const items = useContext(ItemsContext) + items.sort() // Mutating context value + return items +} +``` + +**Correct (copy before mutating):** + +```tsx +function useItems() { + const items = useContext(ItemsContext) + return [...items].sort() +} +``` + +### Rule 5: Values are immutable after being passed to JSX + +**Incorrect (mutating after JSX use):** + +```tsx +function Page({ title }) { + const config = { title } + const element =
+ config.title = 'changed' // Mutating after JSX use + return element +} +``` + +**Correct (don't mutate after passing to JSX):** + +```tsx +function Page({ title }) { + const config = { title } + return
+} +``` + +Reference: [Components and Hooks must be pure](https://react.dev/reference/rules/components-and-hooks-must-be-pure) diff --git a/.agents/skills/review-react/rules/rendering-activity.md b/.agents/skills/review-react/rules/rendering-activity.md new file mode 100644 index 000000000..980103461 --- /dev/null +++ b/.agents/skills/review-react/rules/rendering-activity.md @@ -0,0 +1,51 @@ +--- +title: Use Activity Component for Preserving Hidden UI State +impact: MEDIUM +impactDescription: preserves component state and DOM when toggling visibility +tags: rendering, Activity, show-hide, state-preservation +--- + +## Use Activity Component for Preserving Hidden UI State + +**Impact: MEDIUM (preserves component state and DOM when toggling visibility)** + +When hiding and showing UI (tabs, navigation stacks, offscreen content), unmounting destroys state and DOM. React's `` component (React 19+) hides content while preserving its state. + +**Incorrect (unmounting destroys state):** + +```tsx +function Tabs({ activeTab }) { + return ( +
+ {activeTab === 'home' && } + {activeTab === 'profile' && } +
+ ) +} +// Switching tabs loses scroll position, form input, etc. +``` + +**Correct (Activity preserves state):** + +```tsx +import { unstable_Activity as Activity } from 'react' + +function Tabs({ activeTab }) { + return ( +
+ + + + + + +
+ ) +} +``` + +When `mode="hidden"`, the component tree is rendered at lower priority and hidden via CSS. Effects are cleaned up when hidden and re-run when shown. + +Note: `Activity` is currently `unstable_Activity` in React 19. The API may change. + +Reference: [Activity (React RFC)](https://github.com/reactjs/rfcs/blob/main/text/0extracting-state-updates-from-the-rendering-process.md) diff --git a/.agents/skills/review-react/rules/rendering-conditional-render.md b/.agents/skills/review-react/rules/rendering-conditional-render.md new file mode 100644 index 000000000..2b1016e55 --- /dev/null +++ b/.agents/skills/review-react/rules/rendering-conditional-render.md @@ -0,0 +1,41 @@ +--- +title: Use Ternary for Conditional Rendering +impact: MEDIUM +impactDescription: prevents rendering "0" or "" as visible text +tags: rendering, conditional, ternary, falsy-values +--- + +## Use Ternary for Conditional Rendering + +**Impact: MEDIUM (prevents rendering "0" or "" as visible text)** + +The `&&` operator with non-boolean left operands can render unexpected falsy values like `0` or `""` as visible text in the DOM. + +**Incorrect (falsy number renders as "0"):** + +```tsx +function Notifications({ count }) { + return
{count && }
+ // When count is 0, renders "0" as text +} +``` + +**Correct (explicit ternary):** + +```tsx +function Notifications({ count }) { + return
{count > 0 ? : null}
+} +``` + +**Also correct (double negation for boolean coercion):** + +```tsx +function Notifications({ count }) { + return
{!!count && }
+} +``` + +This is especially important with numeric values (`0`), empty strings (`""`), and `NaN`. Boolean, `null`, and `undefined` are safe with `&&`. + +Reference: [Conditional Rendering](https://react.dev/learn/conditional-rendering) diff --git a/.agents/skills/review-react/rules/rendering-content-visibility.md b/.agents/skills/review-react/rules/rendering-content-visibility.md new file mode 100644 index 000000000..87755f6be --- /dev/null +++ b/.agents/skills/review-react/rules/rendering-content-visibility.md @@ -0,0 +1,55 @@ +--- +title: Use CSS content-visibility for Long Lists +impact: MEDIUM +impactDescription: skips rendering off-screen content, reducing layout and paint cost +tags: rendering, content-visibility, css, performance, long-lists +--- + +## Use CSS content-visibility for Long Lists + +**Impact: MEDIUM (skips rendering off-screen content, reducing layout and paint cost)** + +For long scrollable lists where virtualization is not feasible, CSS `content-visibility: auto` tells the browser to skip layout and painting of off-screen items. + +**Incorrect (all items fully rendered):** + +```tsx +function ActivityList({ activities }) { + return ( +
+ {activities.map(activity => ( +
+ +
+ ))} +
+ ) +} +``` + +**Correct (off-screen items skip rendering):** + +```css +.activity-item { + content-visibility: auto; + contain-intrinsic-size: auto 120px; /* estimated height */ +} +``` + +```tsx +function ActivityList({ activities }) { + return ( +
+ {activities.map(activity => ( +
+ +
+ ))} +
+ ) +} +``` + +`contain-intrinsic-size` provides an estimated size so the scrollbar behaves correctly before items are rendered. Use `auto` keyword to let the browser remember the actual size after first render. + +Reference: [content-visibility (MDN)](https://developer.mozilla.org/en-US/docs/Web/CSS/content-visibility) diff --git a/.agents/skills/review-react/rules/rendering-hoist-jsx.md b/.agents/skills/review-react/rules/rendering-hoist-jsx.md new file mode 100644 index 000000000..241ad8073 --- /dev/null +++ b/.agents/skills/review-react/rules/rendering-hoist-jsx.md @@ -0,0 +1,58 @@ +--- +title: Hoist Static JSX Outside Component Functions +impact: MEDIUM +impactDescription: avoids recreating identical React elements on every render +tags: rendering, static-jsx, hoisting, optimization +--- + +## Hoist Static JSX Outside Component Functions + +**Impact: MEDIUM (avoids recreating identical React elements on every render)** + +JSX that doesn't depend on props, state, or context produces the same output every render. Hoist it to module scope so React creates the element once and reuses it. + +**Incorrect (static JSX recreated every render):** + +```tsx +function Layout({ children }) { + return ( +
+
+

My App

+ +
+ {children} +
+ ) +} +``` + +**Correct (static parts hoisted):** + +```tsx +const header = ( +
+

My App

+ +
+) + +function Layout({ children }) { + return ( +
+ {header} + {children} +
+ ) +} +``` + +React Compiler does this automatically. If you use React Compiler, this optimization is handled for you. + +Reference: [React Compiler](https://react.dev/learn/react-compiler) diff --git a/.agents/skills/review-react/rules/rendering-usetransition-loading.md b/.agents/skills/review-react/rules/rendering-usetransition-loading.md new file mode 100644 index 000000000..e065c8c79 --- /dev/null +++ b/.agents/skills/review-react/rules/rendering-usetransition-loading.md @@ -0,0 +1,60 @@ +--- +title: Prefer useTransition Over Manual Loading State +impact: MEDIUM +impactDescription: avoids flash of loading state and keeps UI responsive +tags: rendering, useTransition, loading, isPending +--- + +## Prefer useTransition Over Manual Loading State + +**Impact: MEDIUM (avoids flash of loading state and keeps UI responsive)** + +Manual boolean loading state (`setLoading(true/false)`) can cause flashes and blocks the UI. `useTransition` gives React control over when to show loading indicators. + +**Incorrect (manual loading state):** + +```tsx +function TabPanel() { + const [tab, setTab] = useState('home') + const [loading, setLoading] = useState(false) + + const switchTab = async (newTab: string) => { + setLoading(true) + setTab(newTab) + setLoading(false) // Flash of loading state + } + + return ( +
+ {loading && } + +
+ ) +} +``` + +**Correct (useTransition):** + +```tsx +function TabPanel() { + const [tab, setTab] = useState('home') + const [isPending, startTransition] = useTransition() + + const switchTab = (newTab: string) => { + startTransition(() => { + setTab(newTab) + }) + } + + return ( +
+ {isPending && } + +
+ ) +} +``` + +`useTransition` lets React keep showing the old UI until the new one is ready, preventing layout shifts and blank screens. + +Reference: [useTransition](https://react.dev/reference/react/useTransition) diff --git a/.agents/skills/review-react/rules/rerender-defer-reads.md b/.agents/skills/review-react/rules/rerender-defer-reads.md new file mode 100644 index 000000000..5ddcf8d1a --- /dev/null +++ b/.agents/skills/review-react/rules/rerender-defer-reads.md @@ -0,0 +1,63 @@ +--- +title: Defer State Reads to Usage Points +impact: MEDIUM +impactDescription: avoids re-renders from state changes only used in callbacks +tags: rerender, state-subscription, useRef, callbacks +--- + +## Defer State Reads to Usage Points + +**Impact: MEDIUM (avoids re-renders from state changes only used in callbacks)** + +If a state value is only consumed in event handlers (not in JSX), subscribing to it via `useState` causes unnecessary re-renders. Defer the read using a ref or by restructuring the component. + +**Incorrect (subscribing to state only used in callback):** + +```tsx +function Form() { + const [draft, setDraft] = useState('') + const [items, setItems] = useState([]) + + // `draft` is read only in this handler, but every keystroke re-renders + const handleAdd = () => { + setItems(prev => [...prev, draft]) + setDraft('') + } + + return ( + <> + setDraft(e.target.value)} /> + + + + ) +} +``` + +**Correct (use ref for value only needed in callbacks):** + +```tsx +function Form() { + const draftRef = useRef('') + const [items, setItems] = useState([]) + const inputRef = useRef(null) + + const handleAdd = () => { + setItems(prev => [...prev, draftRef.current]) + draftRef.current = '' + if (inputRef.current) inputRef.current.value = '' + } + + return ( + <> + { draftRef.current = e.target.value }} /> + + + + ) +} +``` + +Note: This pattern trades React's controlled input for an uncontrolled one. Only apply when the state truly isn't needed in the render output. + +Reference: [useRef](https://react.dev/reference/react/useRef) diff --git a/.agents/skills/review-react/rules/rerender-dependencies.md b/.agents/skills/review-react/rules/rerender-dependencies.md new file mode 100644 index 000000000..084483c23 --- /dev/null +++ b/.agents/skills/review-react/rules/rerender-dependencies.md @@ -0,0 +1,56 @@ +--- +title: Use Primitive Values in Effect Dependencies +impact: MEDIUM +impactDescription: prevents effects from re-firing on every render due to new object references +tags: rerender, useEffect, dependencies, primitives +--- + +## Use Primitive Values in Effect Dependencies + +**Impact: MEDIUM (prevents effects from re-firing on every render due to new object references)** + +Object and array dependencies create new references each render, causing effects to re-run unnecessarily. Extract the primitive values you actually depend on. + +**Incorrect (object in dependency array):** + +```tsx +function UserProfile({ user }) { + useEffect(() => { + document.title = user.name + }, [user]) // Fires every render if `user` is a new object +} +``` + +**Correct (primitive dependency):** + +```tsx +function UserProfile({ user }) { + useEffect(() => { + document.title = user.name + }, [user.name]) // Only fires when name actually changes +} +``` + +**Incorrect (derived object in dependency):** + +```tsx +function Chart({ data }) { + const config = { type: 'bar', data } + + useEffect(() => { + renderChart(config) + }, [config]) // New object every render +} +``` + +**Correct (memoize or use primitives):** + +```tsx +function Chart({ data }) { + useEffect(() => { + renderChart({ type: 'bar', data }) + }, [data]) +} +``` + +Reference: [Removing unnecessary dependencies](https://react.dev/learn/removing-effect-dependencies) diff --git a/.agents/skills/review-react/rules/rerender-derived-state-no-effect.md b/.agents/skills/review-react/rules/rerender-derived-state-no-effect.md new file mode 100644 index 000000000..04f9e9f31 --- /dev/null +++ b/.agents/skills/review-react/rules/rerender-derived-state-no-effect.md @@ -0,0 +1,42 @@ +--- +title: Calculate Derived State During Rendering +impact: MEDIUM +impactDescription: avoids redundant renders and state drift +tags: rerender, derived-state, useEffect, state +--- + +## Calculate Derived State During Rendering + +**Impact: MEDIUM (avoids redundant renders and state drift)** + +If a value can be computed from current props/state, do not store it in state or update it in an effect. Derive it during render to avoid extra renders and state drift. Do not set state in effects solely in response to prop changes; prefer derived values or keyed resets instead. + +**Incorrect (redundant state and effect):** + +```tsx +function Form() { + const [firstName, setFirstName] = useState('First') + const [lastName, setLastName] = useState('Last') + const [fullName, setFullName] = useState('') + + useEffect(() => { + setFullName(firstName + ' ' + lastName) + }, [firstName, lastName]) + + return

{fullName}

+} +``` + +**Correct (derive during render):** + +```tsx +function Form() { + const [firstName, setFirstName] = useState('First') + const [lastName, setLastName] = useState('Last') + const fullName = firstName + ' ' + lastName + + return

{fullName}

+} +``` + +Reference: [You Might Not Need an Effect](https://react.dev/learn/you-might-not-need-an-effect) diff --git a/.agents/skills/review-react/rules/rerender-derived-state.md b/.agents/skills/review-react/rules/rerender-derived-state.md new file mode 100644 index 000000000..d61cb8c2f --- /dev/null +++ b/.agents/skills/review-react/rules/rerender-derived-state.md @@ -0,0 +1,36 @@ +--- +title: Subscribe to Derived Booleans, Not Raw Objects +impact: MEDIUM +impactDescription: reduces re-renders by narrowing subscription scope +tags: rerender, derived-state, selectors, context +--- + +## Subscribe to Derived Booleans, Not Raw Objects + +**Impact: MEDIUM (reduces re-renders by narrowing subscription scope)** + +When a component only needs to know whether a condition is true (e.g., "is the list empty?"), subscribing to the full object causes re-renders whenever any part of the object changes. Derive a boolean or primitive and subscribe to that instead. + +**Incorrect (subscribing to full array to check emptiness):** + +```tsx +function EmptyBanner() { + const items = useStore(state => state.items) // Re-renders on any item change + if (items.length > 0) return null + return

No items yet

+} +``` + +**Correct (subscribe to derived boolean):** + +```tsx +function EmptyBanner() { + const isEmpty = useStore(state => state.items.length === 0) + if (!isEmpty) return null + return

No items yet

+} +``` + +This pattern works with any external store, context selector, or state management library that supports selectors. + +Reference: [useSyncExternalStore](https://react.dev/reference/react/useSyncExternalStore) diff --git a/.agents/skills/review-react/rules/rerender-functional-setstate.md b/.agents/skills/review-react/rules/rerender-functional-setstate.md new file mode 100644 index 000000000..ed75e811f --- /dev/null +++ b/.agents/skills/review-react/rules/rerender-functional-setstate.md @@ -0,0 +1,44 @@ +--- +title: Use Functional setState for Stable Callbacks +impact: MEDIUM +impactDescription: eliminates state from callback dependencies, enabling stable references +tags: rerender, setState, useCallback, functional-update +--- + +## Use Functional setState for Stable Callbacks + +**Impact: MEDIUM (eliminates state from callback dependencies, enabling stable references)** + +When a callback reads current state only to compute the next state, use the functional form of setState. This removes the state variable from the dependency array, producing a stable callback reference. + +**Incorrect (state in dependency causes new callback each update):** + +```tsx +function Counter() { + const [count, setCount] = useState(0) + + const increment = useCallback(() => { + setCount(count + 1) + }, [count]) // New function every time count changes + + return +} +``` + +**Correct (functional update removes dependency):** + +```tsx +function Counter() { + const [count, setCount] = useState(0) + + const increment = useCallback(() => { + setCount(prev => prev + 1) + }, []) // Stable reference + + return +} +``` + +This pattern also avoids stale closure bugs where the callback captures an outdated `count` value. + +Reference: [useState](https://react.dev/reference/react/useState#updating-state-based-on-the-previous-state) diff --git a/.agents/skills/review-react/rules/rerender-lazy-state-init.md b/.agents/skills/review-react/rules/rerender-lazy-state-init.md new file mode 100644 index 000000000..32233ddf0 --- /dev/null +++ b/.agents/skills/review-react/rules/rerender-lazy-state-init.md @@ -0,0 +1,35 @@ +--- +title: Use Lazy State Initialization +impact: MEDIUM +impactDescription: avoids running expensive initialization on every render +tags: rerender, useState, lazy-init, initialization +--- + +## Use Lazy State Initialization + +**Impact: MEDIUM (avoids running expensive initialization on every render)** + +When you pass a value to `useState`, it's evaluated on every render even though React only uses it on the first render. Pass a function instead for expensive computations. + +**Incorrect (expensive computation runs every render):** + +```tsx +function Editor({ content }) { + // parseMarkdown runs on EVERY render, result is discarded after first + const [parsed, setParsed] = useState(parseMarkdown(content)) + return +} +``` + +**Correct (lazy initializer runs only on first render):** + +```tsx +function Editor({ content }) { + const [parsed, setParsed] = useState(() => parseMarkdown(content)) + return +} +``` + +This applies to any non-trivial computation: parsing, complex object construction, reading from storage, etc. + +Reference: [useState](https://react.dev/reference/react/useState#avoiding-recreating-the-initial-state) diff --git a/.agents/skills/review-react/rules/rerender-memo-with-default-value.md b/.agents/skills/review-react/rules/rerender-memo-with-default-value.md new file mode 100644 index 000000000..6be485084 --- /dev/null +++ b/.agents/skills/review-react/rules/rerender-memo-with-default-value.md @@ -0,0 +1,43 @@ +--- +title: Hoist Default Non-Primitive Props Outside Memo +impact: MEDIUM +impactDescription: prevents memo from being defeated by new object references +tags: rerender, memo, default-props, reference-identity +--- + +## Hoist Default Non-Primitive Props Outside Memo + +**Impact: MEDIUM (prevents memo from being defeated by new object references)** + +When a memoized component receives a default value like `{}` or `[]` inline, a new reference is created each render, defeating the memo. + +**Incorrect (default value creates new reference each render):** + +```tsx +const List = memo(function List({ items = [], config = {} }) { + return
    {items.map(i =>
  • {i.name}
  • )}
+}) + +function Parent() { + // items and config will be new refs every render if undefined + return +} +``` + +**Correct (hoist defaults to module scope):** + +```tsx +const EMPTY_ITEMS: Item[] = [] +const DEFAULT_CONFIG: Config = {} + +const List = memo(function List({ + items = EMPTY_ITEMS, + config = DEFAULT_CONFIG, +}) { + return
    {items.map(i =>
  • {i.name}
  • )}
+}) +``` + +This also applies to callback props. Use `useCallback` or hoist the function to module scope. + +Reference: [memo](https://react.dev/reference/react/memo) diff --git a/.agents/skills/review-react/rules/rerender-memo.md b/.agents/skills/review-react/rules/rerender-memo.md new file mode 100644 index 000000000..58fa0b940 --- /dev/null +++ b/.agents/skills/review-react/rules/rerender-memo.md @@ -0,0 +1,51 @@ +--- +title: Extract Memoized Child Components +impact: MEDIUM +impactDescription: prevents expensive subtrees from re-rendering unnecessarily +tags: rerender, memo, React.memo, memoization +--- + +## Extract Memoized Child Components + +**Impact: MEDIUM (prevents expensive subtrees from re-rendering unnecessarily)** + +When a parent re-renders, all children re-render too. Extract expensive children into `React.memo()` components so they only re-render when their props actually change. + +**Incorrect (expensive child re-renders on every parent update):** + +```tsx +function Dashboard({ data, onRefresh }) { + const [filter, setFilter] = useState('') + + return ( +
+ setFilter(e.target.value)} /> + +
+ ) +} +``` + +**Correct (wrap expensive child in memo):** + +```tsx +const ExpensiveChart = memo(function ExpensiveChart({ data }: { data: Data }) { + // Only re-renders when `data` changes + return {/* heavy rendering */} +}) + +function Dashboard({ data }) { + const [filter, setFilter] = useState('') + + return ( +
+ setFilter(e.target.value)} /> + +
+ ) +} +``` + +Only use `memo` when profiling shows the child is expensive. Premature memoization adds complexity without benefit. + +Reference: [memo](https://react.dev/reference/react/memo) diff --git a/.agents/skills/review-react/rules/rerender-move-effect-to-event.md b/.agents/skills/review-react/rules/rerender-move-effect-to-event.md new file mode 100644 index 000000000..ce9527679 --- /dev/null +++ b/.agents/skills/review-react/rules/rerender-move-effect-to-event.md @@ -0,0 +1,57 @@ +--- +title: Move Interaction Logic from Effects to Event Handlers +impact: MEDIUM +impactDescription: eliminates unnecessary effect cycles and simplifies data flow +tags: rerender, useEffect, event-handlers, interaction +--- + +## Move Interaction Logic from Effects to Event Handlers + +**Impact: MEDIUM (eliminates unnecessary effect cycles and simplifies data flow)** + +Effects are for synchronizing with external systems, not for responding to user interactions. When logic should run in response to a specific user action, put it in the event handler. + +**Incorrect (effect responding to state set by event):** + +```tsx +function SearchPage() { + const [query, setQuery] = useState('') + const [submitted, setSubmitted] = useState(false) + + useEffect(() => { + if (submitted) { + fetchResults(query) + setSubmitted(false) + } + }, [submitted, query]) + + return ( +
setSubmitted(true)}> + setQuery(e.target.value)} /> +
+ ) +} +``` + +**Correct (logic in event handler):** + +```tsx +function SearchPage() { + const [query, setQuery] = useState('') + + const handleSubmit = (e: FormEvent) => { + e.preventDefault() + fetchResults(query) + } + + return ( +
+ setQuery(e.target.value)} /> +
+ ) +} +``` + +A useful heuristic: if the code runs because the user did something (click, submit, type), it belongs in an event handler. If it runs because something needs to stay in sync (data subscription, DOM measurement), it belongs in an effect. + +Reference: [You Might Not Need an Effect](https://react.dev/learn/you-might-not-need-an-effect) diff --git a/.agents/skills/review-react/rules/rerender-no-inline-components.md b/.agents/skills/review-react/rules/rerender-no-inline-components.md new file mode 100644 index 000000000..c91b7d1da --- /dev/null +++ b/.agents/skills/review-react/rules/rerender-no-inline-components.md @@ -0,0 +1,43 @@ +--- +title: Never Define Components Inside Components +impact: MEDIUM +impactDescription: causes full remount and state loss on every parent render +tags: rerender, inline-components, state-loss +--- + +## Never Define Components Inside Components + +**Impact: MEDIUM (causes full remount and state loss on every parent render)** + +When a component is defined inside another component, React creates a new component type on every render. This forces React to unmount and remount the inner component, destroying all its state. + +**Incorrect (component defined inside parent):** + +```tsx +function Parent() { + // New function identity every render = new component type + function Child() { + const [count, setCount] = useState(0) + return + } + + return +} +``` + +**Correct (component defined outside parent):** + +```tsx +function Child() { + const [count, setCount] = useState(0) + return +} + +function Parent() { + return +} +``` + +This applies to all forms: arrow functions, function declarations, and class expressions inside render. + +Reference: [Don't define components inside other components](https://react.dev/learn/your-first-component#nesting-and-organizing-components) diff --git a/.agents/skills/review-react/rules/rerender-simple-expression-in-memo.md b/.agents/skills/review-react/rules/rerender-simple-expression-in-memo.md new file mode 100644 index 000000000..9058f9819 --- /dev/null +++ b/.agents/skills/review-react/rules/rerender-simple-expression-in-memo.md @@ -0,0 +1,34 @@ +--- +title: Don't useMemo for Simple Primitive Expressions +impact: MEDIUM +impactDescription: useMemo overhead exceeds the cost of trivial computations +tags: rerender, useMemo, premature-optimization +--- + +## Don't useMemo for Simple Primitive Expressions + +**Impact: MEDIUM (useMemo overhead exceeds the cost of trivial computations)** + +`useMemo` has overhead (dependency comparison, closure allocation). For simple math or string operations that produce primitives, the memoization cost exceeds the computation cost. + +**Incorrect (memoizing trivial expression):** + +```tsx +function Progress({ current, total }) { + const percentage = useMemo(() => Math.round((current / total) * 100), [current, total]) + return {percentage}% +} +``` + +**Correct (compute inline):** + +```tsx +function Progress({ current, total }) { + const percentage = Math.round((current / total) * 100) + return {percentage}% +} +``` + +Reserve `useMemo` for expensive computations (large array transformations, complex calculations) or when the result is an object/array passed to a memoized child. + +Reference: [useMemo](https://react.dev/reference/react/useMemo) diff --git a/.agents/skills/review-react/rules/rerender-transitions.md b/.agents/skills/review-react/rules/rerender-transitions.md new file mode 100644 index 000000000..00ee3214d --- /dev/null +++ b/.agents/skills/review-react/rules/rerender-transitions.md @@ -0,0 +1,60 @@ +--- +title: Use startTransition for Non-Urgent Updates +impact: MEDIUM +impactDescription: keeps UI responsive during expensive state updates +tags: rerender, useTransition, startTransition, concurrent +--- + +## Use startTransition for Non-Urgent Updates + +**Impact: MEDIUM (keeps UI responsive during expensive state updates)** + +When a state update triggers expensive rendering (large lists, complex computations), wrapping it in `startTransition` tells React it can be interrupted by more urgent updates like typing. + +**Incorrect (expensive update blocks input):** + +```tsx +function FilterableList({ items }) { + const [filter, setFilter] = useState('') + + const handleChange = (e: ChangeEvent) => { + setFilter(e.target.value) // Expensive filter blocks typing + } + + const filtered = items.filter(item => item.name.includes(filter)) + + return ( + <> + + + + ) +} +``` + +**Correct (defer expensive update with transition):** + +```tsx +function FilterableList({ items }) { + const [input, setInput] = useState('') + const [filter, setFilter] = useState('') + + const handleChange = (e: ChangeEvent) => { + setInput(e.target.value) // Urgent: update input immediately + startTransition(() => { + setFilter(e.target.value) // Non-urgent: can be interrupted + }) + } + + const filtered = items.filter(item => item.name.includes(filter)) + + return ( + <> + + + + ) +} +``` + +Reference: [startTransition](https://react.dev/reference/react/startTransition) diff --git a/.agents/skills/review-react/rules/rerender-use-ref-transient-values.md b/.agents/skills/review-react/rules/rerender-use-ref-transient-values.md new file mode 100644 index 000000000..dd3e6123e --- /dev/null +++ b/.agents/skills/review-react/rules/rerender-use-ref-transient-values.md @@ -0,0 +1,55 @@ +--- +title: Use Refs for Frequently-Changing Transient Values +impact: MEDIUM +impactDescription: avoids high-frequency re-renders from values not needed in JSX +tags: rerender, useRef, transient, scroll, mouse, animation +--- + +## Use Refs for Frequently-Changing Transient Values + +**Impact: MEDIUM (avoids high-frequency re-renders from values not needed in JSX)** + +Values that change at high frequency (scroll position, mouse coordinates, animation progress) but aren't displayed in JSX should be stored in refs to avoid triggering re-renders. + +**Incorrect (state for high-frequency value):** + +```tsx +function Scroller() { + const [scrollY, setScrollY] = useState(0) + + useEffect(() => { + const handler = () => setScrollY(window.scrollY) // Re-render per scroll event + window.addEventListener('scroll', handler) + return () => window.removeEventListener('scroll', handler) + }, []) + + // scrollY only used to check a threshold, not displayed + useEffect(() => { + if (scrollY > 100) showFloatingButton() + }, [scrollY]) +} +``` + +**Correct (ref for transient value):** + +```tsx +function Scroller() { + const scrollY = useRef(0) + const [showButton, setShowButton] = useState(false) + + useEffect(() => { + const handler = () => { + scrollY.current = window.scrollY + setShowButton(scrollY.current > 100) // Only re-renders when boolean changes + } + window.addEventListener('scroll', handler, { passive: true }) + return () => window.removeEventListener('scroll', handler) + }, []) + + return showButton ? : null +} +``` + +Combine with `requestAnimationFrame` for animation-related values to further reduce work. + +Reference: [useRef](https://react.dev/reference/react/useRef) diff --git a/.claude/skills/review-react b/.claude/skills/review-react new file mode 120000 index 000000000..49fca0126 --- /dev/null +++ b/.claude/skills/review-react @@ -0,0 +1 @@ +../../.agents/skills/review-react \ No newline at end of file diff --git a/AGENTS.md b/AGENTS.md index 0c0df1688..63a015a37 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -207,6 +207,10 @@ The Future API maintains compatibility with existing code while preparing for St - TypeScript declarations generated separately - Monorepo managed with Yarn workspaces and ultra runner +## Skills + +- React code review guidelines: `.agents/skills/review-react/` + ## Important Notes - Always use `yarn` commands (not npm) From 5a2d10a18f7cfcd21d0eef013d9ee328a773b6a1 Mon Sep 17 00:00:00 2001 From: "JH.Lee" Date: Wed, 18 Mar 2026 11:04:21 +0900 Subject: [PATCH 2/2] fix(agents): sync all review-react rules with vercel-labs/agent-skills originals Co-Authored-By: Claude Opus 4.6 (1M context) --- .../rules/advanced-event-handler-refs.md | 64 +++++++------ .../review-react/rules/advanced-init-once.md | 60 ++++--------- .../review-react/rules/rendering-activity.md | 49 +++------- .../rules/rendering-conditional-render.md | 51 ++++++----- .../rules/rendering-content-visibility.md | 53 ++++------- .../review-react/rules/rendering-hoist-jsx.md | 52 +++++------ .../rules/rendering-usetransition-loading.md | 75 +++++++++------- .../rules/rerender-defer-reads.md | 64 +++++-------- .../rules/rerender-dependencies.md | 69 ++++++-------- .../rules/rerender-derived-state-no-effect.md | 4 +- .../rules/rerender-derived-state.md | 35 +++----- .../rules/rerender-functional-setstate.md | 82 +++++++++++------ .../rules/rerender-lazy-state-init.md | 55 ++++++++---- .../rules/rerender-memo-with-default-value.md | 47 +++++----- .../review-react/rules/rerender-memo.md | 51 +++++------ .../rules/rerender-move-effect-to-event.md | 52 +++++------ .../rules/rerender-no-inline-components.md | 89 +++++++++++++------ .../rerender-simple-expression-in-memo.md | 41 ++++----- .../rules/rerender-transitions.md | 70 ++++++--------- .../rerender-use-ref-transient-values.md | 80 ++++++++++------- 20 files changed, 553 insertions(+), 590 deletions(-) diff --git a/.agents/skills/review-react/rules/advanced-event-handler-refs.md b/.agents/skills/review-react/rules/advanced-event-handler-refs.md index 518662985..97e7ade24 100644 --- a/.agents/skills/review-react/rules/advanced-event-handler-refs.md +++ b/.agents/skills/review-react/rules/advanced-event-handler-refs.md @@ -1,49 +1,55 @@ --- -title: Store Event Handlers in Refs for Stable Callbacks +title: Store Event Handlers in Refs impact: LOW -impactDescription: provides stable function identity without stale closures -tags: advanced, refs, event-handlers, useRef, stable-callback +impactDescription: stable subscriptions +tags: advanced, hooks, refs, event-handlers, optimization --- -## Store Event Handlers in Refs for Stable Callbacks +## Store Event Handlers in Refs -**Impact: LOW (provides stable function identity without stale closures)** +Store callbacks in refs when used in effects that shouldn't re-subscribe on callback changes. -When you need a callback that always calls the latest version of a function without changing identity, store the handler in a ref. This avoids both stale closures and unnecessary re-renders from changing callback props. - -**Incorrect (callback changes identity, causing child re-renders):** +**Incorrect (re-subscribes on every render):** ```tsx -function Chat({ roomId }) { - const [message, setMessage] = useState('') +function useWindowEvent(event: string, handler: (e) => void) { + useEffect(() => { + window.addEventListener(event, handler) + return () => window.removeEventListener(event, handler) + }, [event, handler]) +} +``` - const handleSend = useCallback(() => { - sendMessage(roomId, message) - }, [roomId, message]) // Changes on every keystroke +**Correct (stable subscription):** - return +```tsx +function useWindowEvent(event: string, handler: (e) => void) { + const handlerRef = useRef(handler) + useEffect(() => { + handlerRef.current = handler + }, [handler]) + + useEffect(() => { + const listener = (e) => handlerRef.current(e) + window.addEventListener(event, listener) + return () => window.removeEventListener(event, listener) + }, [event]) } ``` -**Correct (ref-based stable callback):** +**Alternative: use `useEffectEvent` if you're on latest React:** ```tsx -function Chat({ roomId }) { - const [message, setMessage] = useState('') - - const handleSendRef = useRef(() => {}) - handleSendRef.current = () => { - sendMessage(roomId, message) - } +import { useEffectEvent } from 'react' - const handleSend = useCallback(() => { - handleSendRef.current() - }, []) +function useWindowEvent(event: string, handler: (e) => void) { + const onEvent = useEffectEvent(handler) - return + useEffect(() => { + window.addEventListener(event, onEvent) + return () => window.removeEventListener(event, onEvent) + }, [event]) } ``` -This is the pattern behind `useEffectEvent` (experimental). When `useEffectEvent` stabilizes, prefer it over manual ref management. - -Reference: [Separating Events from Effects](https://react.dev/learn/separating-events-from-effects) +`useEffectEvent` provides a cleaner API for the same pattern: it creates a stable function reference that always calls the latest version of the handler. diff --git a/.agents/skills/review-react/rules/advanced-init-once.md b/.agents/skills/review-react/rules/advanced-init-once.md index a53c42240..73ee38e5e 100644 --- a/.agents/skills/review-react/rules/advanced-init-once.md +++ b/.agents/skills/review-react/rules/advanced-init-once.md @@ -1,68 +1,42 @@ --- -title: Initialize App-Level Singletons Once -impact: LOW -impactDescription: prevents duplicate initialization in Strict Mode and re-mounts -tags: advanced, initialization, singleton, module-scope +title: Initialize App Once, Not Per Mount +impact: LOW-MEDIUM +impactDescription: avoids duplicate init in development +tags: initialization, useEffect, app-startup, side-effects --- -## Initialize App-Level Singletons Once +## Initialize App Once, Not Per Mount -**Impact: LOW (prevents duplicate initialization in Strict Mode and re-mounts)** +Do not put app-wide initialization that must run once per app load inside `useEffect([])` of a component. Components can remount and effects will re-run. Use a module-level guard or top-level init in the entry module instead. -App-level setup (analytics, error tracking, SDK initialization) should run once per app load, not per component mount. Strict Mode double-invokes effects, causing duplicate initialization. - -**Incorrect (initialization in effect runs twice in Strict Mode):** +**Incorrect (runs twice in dev, re-runs on remount):** ```tsx -function App() { +function Comp() { useEffect(() => { - analytics.init('key') // Runs twice in Strict Mode - errorTracker.setup() // May cause duplicate event listeners + loadFromStorage() + checkAuthToken() }, []) - return -} -``` - -**Correct (module-level initialization):** - -```tsx -// app-init.ts -let initialized = false - -export function initApp() { - if (initialized) return - initialized = true - analytics.init('key') - errorTracker.setup() -} -``` - -```tsx -// App.tsx -import { initApp } from './app-init' - -initApp() // Runs once at module evaluation - -function App() { - return + // ... } ``` -**Also correct (top-level flag guard):** +**Correct (once per app load):** ```tsx let didInit = false -function App() { +function Comp() { useEffect(() => { if (didInit) return didInit = true - analytics.init('key') + loadFromStorage() + checkAuthToken() }, []) - return + // ... } ``` -Reference: [How to handle the Effect firing twice in development](https://react.dev/learn/synchronizing-with-effects#how-to-handle-the-effect-firing-twice-in-development) +Reference: [Initializing the application](https://react.dev/learn/you-might-not-need-an-effect#initializing-the-application) diff --git a/.agents/skills/review-react/rules/rendering-activity.md b/.agents/skills/review-react/rules/rendering-activity.md index 980103461..c957a490b 100644 --- a/.agents/skills/review-react/rules/rendering-activity.md +++ b/.agents/skills/review-react/rules/rendering-activity.md @@ -1,51 +1,26 @@ --- -title: Use Activity Component for Preserving Hidden UI State +title: Use Activity Component for Show/Hide impact: MEDIUM -impactDescription: preserves component state and DOM when toggling visibility -tags: rendering, Activity, show-hide, state-preservation +impactDescription: preserves state/DOM +tags: rendering, activity, visibility, state-preservation --- -## Use Activity Component for Preserving Hidden UI State +## Use Activity Component for Show/Hide -**Impact: MEDIUM (preserves component state and DOM when toggling visibility)** +Use React's `` to preserve state/DOM for expensive components that frequently toggle visibility. -When hiding and showing UI (tabs, navigation stacks, offscreen content), unmounting destroys state and DOM. React's `` component (React 19+) hides content while preserving its state. - -**Incorrect (unmounting destroys state):** - -```tsx -function Tabs({ activeTab }) { - return ( -
- {activeTab === 'home' && } - {activeTab === 'profile' && } -
- ) -} -// Switching tabs loses scroll position, form input, etc. -``` - -**Correct (Activity preserves state):** +**Usage:** ```tsx -import { unstable_Activity as Activity } from 'react' +import { Activity } from 'react' -function Tabs({ activeTab }) { +function Dropdown({ isOpen }: Props) { return ( -
- - - - - - -
+ + + ) } ``` -When `mode="hidden"`, the component tree is rendered at lower priority and hidden via CSS. Effects are cleaned up when hidden and re-run when shown. - -Note: `Activity` is currently `unstable_Activity` in React 19. The API may change. - -Reference: [Activity (React RFC)](https://github.com/reactjs/rfcs/blob/main/text/0extracting-state-updates-from-the-rendering-process.md) +Avoids expensive re-renders and state loss. diff --git a/.agents/skills/review-react/rules/rendering-conditional-render.md b/.agents/skills/review-react/rules/rendering-conditional-render.md index 2b1016e55..7e866f585 100644 --- a/.agents/skills/review-react/rules/rendering-conditional-render.md +++ b/.agents/skills/review-react/rules/rendering-conditional-render.md @@ -1,41 +1,40 @@ --- -title: Use Ternary for Conditional Rendering -impact: MEDIUM -impactDescription: prevents rendering "0" or "" as visible text -tags: rendering, conditional, ternary, falsy-values +title: Use Explicit Conditional Rendering +impact: LOW +impactDescription: prevents rendering 0 or NaN +tags: rendering, conditional, jsx, falsy-values --- -## Use Ternary for Conditional Rendering +## Use Explicit Conditional Rendering -**Impact: MEDIUM (prevents rendering "0" or "" as visible text)** +Use explicit ternary operators (`? :`) instead of `&&` for conditional rendering when the condition can be `0`, `NaN`, or other falsy values that render. -The `&&` operator with non-boolean left operands can render unexpected falsy values like `0` or `""` as visible text in the DOM. - -**Incorrect (falsy number renders as "0"):** +**Incorrect (renders "0" when count is 0):** ```tsx -function Notifications({ count }) { - return
{count && }
- // When count is 0, renders "0" as text +function Badge({ count }: { count: number }) { + return ( +
+ {count && {count}} +
+ ) } -``` -**Correct (explicit ternary):** - -```tsx -function Notifications({ count }) { - return
{count > 0 ? : null}
-} +// When count = 0, renders:
0
+// When count = 5, renders:
5
``` -**Also correct (double negation for boolean coercion):** +**Correct (renders nothing when count is 0):** ```tsx -function Notifications({ count }) { - return
{!!count && }
+function Badge({ count }: { count: number }) { + return ( +
+ {count > 0 ? {count} : null} +
+ ) } -``` -This is especially important with numeric values (`0`), empty strings (`""`), and `NaN`. Boolean, `null`, and `undefined` are safe with `&&`. - -Reference: [Conditional Rendering](https://react.dev/learn/conditional-rendering) +// When count = 0, renders:
+// When count = 5, renders:
5
+``` diff --git a/.agents/skills/review-react/rules/rendering-content-visibility.md b/.agents/skills/review-react/rules/rendering-content-visibility.md index 87755f6be..aa6656362 100644 --- a/.agents/skills/review-react/rules/rendering-content-visibility.md +++ b/.agents/skills/review-react/rules/rendering-content-visibility.md @@ -1,48 +1,33 @@ --- -title: Use CSS content-visibility for Long Lists -impact: MEDIUM -impactDescription: skips rendering off-screen content, reducing layout and paint cost -tags: rendering, content-visibility, css, performance, long-lists +title: CSS content-visibility for Long Lists +impact: HIGH +impactDescription: faster initial render +tags: rendering, css, content-visibility, long-lists --- -## Use CSS content-visibility for Long Lists +## CSS content-visibility for Long Lists -**Impact: MEDIUM (skips rendering off-screen content, reducing layout and paint cost)** +Apply `content-visibility: auto` to defer off-screen rendering. -For long scrollable lists where virtualization is not feasible, CSS `content-visibility: auto` tells the browser to skip layout and painting of off-screen items. - -**Incorrect (all items fully rendered):** - -```tsx -function ActivityList({ activities }) { - return ( -
- {activities.map(activity => ( -
- -
- ))} -
- ) -} -``` - -**Correct (off-screen items skip rendering):** +**CSS:** ```css -.activity-item { +.message-item { content-visibility: auto; - contain-intrinsic-size: auto 120px; /* estimated height */ + contain-intrinsic-size: 0 80px; } ``` +**Example:** + ```tsx -function ActivityList({ activities }) { +function MessageList({ messages }: { messages: Message[] }) { return ( -
- {activities.map(activity => ( -
- +
+ {messages.map(msg => ( +
+ +
{msg.content}
))}
@@ -50,6 +35,4 @@ function ActivityList({ activities }) { } ``` -`contain-intrinsic-size` provides an estimated size so the scrollbar behaves correctly before items are rendered. Use `auto` keyword to let the browser remember the actual size after first render. - -Reference: [content-visibility (MDN)](https://developer.mozilla.org/en-US/docs/Web/CSS/content-visibility) +For 1000 messages, browser skips layout/paint for ~990 off-screen items (10× faster initial render). diff --git a/.agents/skills/review-react/rules/rendering-hoist-jsx.md b/.agents/skills/review-react/rules/rendering-hoist-jsx.md index 241ad8073..32d2f3fce 100644 --- a/.agents/skills/review-react/rules/rendering-hoist-jsx.md +++ b/.agents/skills/review-react/rules/rendering-hoist-jsx.md @@ -1,58 +1,46 @@ --- -title: Hoist Static JSX Outside Component Functions -impact: MEDIUM -impactDescription: avoids recreating identical React elements on every render -tags: rendering, static-jsx, hoisting, optimization +title: Hoist Static JSX Elements +impact: LOW +impactDescription: avoids re-creation +tags: rendering, jsx, static, optimization --- -## Hoist Static JSX Outside Component Functions +## Hoist Static JSX Elements -**Impact: MEDIUM (avoids recreating identical React elements on every render)** +Extract static JSX outside components to avoid re-creation. -JSX that doesn't depend on props, state, or context produces the same output every render. Hoist it to module scope so React creates the element once and reuses it. - -**Incorrect (static JSX recreated every render):** +**Incorrect (recreates element every render):** ```tsx -function Layout({ children }) { +function LoadingSkeleton() { + return
+} + +function Container() { return (
-
-

My App

- -
- {children} + {loading && }
) } ``` -**Correct (static parts hoisted):** +**Correct (reuses same element):** ```tsx -const header = ( -
-

My App

- -
+const loadingSkeleton = ( +
) -function Layout({ children }) { +function Container() { return (
- {header} - {children} + {loading && loadingSkeleton}
) } ``` -React Compiler does this automatically. If you use React Compiler, this optimization is handled for you. +This is especially helpful for large and static SVG nodes, which can be expensive to recreate on every render. -Reference: [React Compiler](https://react.dev/learn/react-compiler) +**Note:** If your project has [React Compiler](https://react.dev/learn/react-compiler) enabled, the compiler automatically hoists static JSX elements and optimizes component re-renders, making manual hoisting unnecessary. diff --git a/.agents/skills/review-react/rules/rendering-usetransition-loading.md b/.agents/skills/review-react/rules/rendering-usetransition-loading.md index e065c8c79..0c1b0b98e 100644 --- a/.agents/skills/review-react/rules/rendering-usetransition-loading.md +++ b/.agents/skills/review-react/rules/rendering-usetransition-loading.md @@ -1,60 +1,75 @@ --- -title: Prefer useTransition Over Manual Loading State -impact: MEDIUM -impactDescription: avoids flash of loading state and keeps UI responsive -tags: rendering, useTransition, loading, isPending +title: Use useTransition Over Manual Loading States +impact: LOW +impactDescription: reduces re-renders and improves code clarity +tags: rendering, transitions, useTransition, loading, state --- -## Prefer useTransition Over Manual Loading State +## Use useTransition Over Manual Loading States -**Impact: MEDIUM (avoids flash of loading state and keeps UI responsive)** - -Manual boolean loading state (`setLoading(true/false)`) can cause flashes and blocks the UI. `useTransition` gives React control over when to show loading indicators. +Use `useTransition` instead of manual `useState` for loading states. This provides built-in `isPending` state and automatically manages transitions. **Incorrect (manual loading state):** ```tsx -function TabPanel() { - const [tab, setTab] = useState('home') - const [loading, setLoading] = useState(false) - - const switchTab = async (newTab: string) => { - setLoading(true) - setTab(newTab) - setLoading(false) // Flash of loading state +function SearchResults() { + const [query, setQuery] = useState('') + const [results, setResults] = useState([]) + const [isLoading, setIsLoading] = useState(false) + + const handleSearch = async (value: string) => { + setIsLoading(true) + setQuery(value) + const data = await fetchResults(value) + setResults(data) + setIsLoading(false) } return ( -
- {loading && } - -
+ <> + handleSearch(e.target.value)} /> + {isLoading && } + + ) } ``` -**Correct (useTransition):** +**Correct (useTransition with built-in pending state):** ```tsx -function TabPanel() { - const [tab, setTab] = useState('home') +import { useTransition, useState } from 'react' + +function SearchResults() { + const [query, setQuery] = useState('') + const [results, setResults] = useState([]) const [isPending, startTransition] = useTransition() - const switchTab = (newTab: string) => { - startTransition(() => { - setTab(newTab) + const handleSearch = (value: string) => { + setQuery(value) // Update input immediately + + startTransition(async () => { + // Fetch and update results + const data = await fetchResults(value) + setResults(data) }) } return ( -
+ <> + handleSearch(e.target.value)} /> {isPending && } - -
+ + ) } ``` -`useTransition` lets React keep showing the old UI until the new one is ready, preventing layout shifts and blank screens. +**Benefits:** + +- **Automatic pending state**: No need to manually manage `setIsLoading(true/false)` +- **Error resilience**: Pending state correctly resets even if the transition throws +- **Better responsiveness**: Keeps the UI responsive during updates +- **Interrupt handling**: New transitions automatically cancel pending ones Reference: [useTransition](https://react.dev/reference/react/useTransition) diff --git a/.agents/skills/review-react/rules/rerender-defer-reads.md b/.agents/skills/review-react/rules/rerender-defer-reads.md index 5ddcf8d1a..e867c95f0 100644 --- a/.agents/skills/review-react/rules/rerender-defer-reads.md +++ b/.agents/skills/review-react/rules/rerender-defer-reads.md @@ -1,63 +1,39 @@ --- -title: Defer State Reads to Usage Points +title: Defer State Reads to Usage Point impact: MEDIUM -impactDescription: avoids re-renders from state changes only used in callbacks -tags: rerender, state-subscription, useRef, callbacks +impactDescription: avoids unnecessary subscriptions +tags: rerender, searchParams, localStorage, optimization --- -## Defer State Reads to Usage Points +## Defer State Reads to Usage Point -**Impact: MEDIUM (avoids re-renders from state changes only used in callbacks)** +Don't subscribe to dynamic state (searchParams, localStorage) if you only read it inside callbacks. -If a state value is only consumed in event handlers (not in JSX), subscribing to it via `useState` causes unnecessary re-renders. Defer the read using a ref or by restructuring the component. - -**Incorrect (subscribing to state only used in callback):** +**Incorrect (subscribes to all searchParams changes):** ```tsx -function Form() { - const [draft, setDraft] = useState('') - const [items, setItems] = useState([]) - - // `draft` is read only in this handler, but every keystroke re-renders - const handleAdd = () => { - setItems(prev => [...prev, draft]) - setDraft('') +function ShareButton({ chatId }: { chatId: string }) { + const searchParams = useSearchParams() + + const handleShare = () => { + const ref = searchParams.get('ref') + shareChat(chatId, { ref }) } - return ( - <> - setDraft(e.target.value)} /> - - - - ) + return } ``` -**Correct (use ref for value only needed in callbacks):** +**Correct (reads on demand, no subscription):** ```tsx -function Form() { - const draftRef = useRef('') - const [items, setItems] = useState([]) - const inputRef = useRef(null) - - const handleAdd = () => { - setItems(prev => [...prev, draftRef.current]) - draftRef.current = '' - if (inputRef.current) inputRef.current.value = '' +function ShareButton({ chatId }: { chatId: string }) { + const handleShare = () => { + const params = new URLSearchParams(window.location.search) + const ref = params.get('ref') + shareChat(chatId, { ref }) } - return ( - <> - { draftRef.current = e.target.value }} /> - - - - ) + return } ``` - -Note: This pattern trades React's controlled input for an uncontrolled one. Only apply when the state truly isn't needed in the render output. - -Reference: [useRef](https://react.dev/reference/react/useRef) diff --git a/.agents/skills/review-react/rules/rerender-dependencies.md b/.agents/skills/review-react/rules/rerender-dependencies.md index 084483c23..47a4d9268 100644 --- a/.agents/skills/review-react/rules/rerender-dependencies.md +++ b/.agents/skills/review-react/rules/rerender-dependencies.md @@ -1,56 +1,45 @@ --- -title: Use Primitive Values in Effect Dependencies -impact: MEDIUM -impactDescription: prevents effects from re-firing on every render due to new object references -tags: rerender, useEffect, dependencies, primitives +title: Narrow Effect Dependencies +impact: LOW +impactDescription: minimizes effect re-runs +tags: rerender, useEffect, dependencies, optimization --- -## Use Primitive Values in Effect Dependencies +## Narrow Effect Dependencies -**Impact: MEDIUM (prevents effects from re-firing on every render due to new object references)** +Specify primitive dependencies instead of objects to minimize effect re-runs. -Object and array dependencies create new references each render, causing effects to re-run unnecessarily. Extract the primitive values you actually depend on. - -**Incorrect (object in dependency array):** +**Incorrect (re-runs on any user field change):** ```tsx -function UserProfile({ user }) { - useEffect(() => { - document.title = user.name - }, [user]) // Fires every render if `user` is a new object -} +useEffect(() => { + console.log(user.id) +}, [user]) ``` -**Correct (primitive dependency):** +**Correct (re-runs only when id changes):** ```tsx -function UserProfile({ user }) { - useEffect(() => { - document.title = user.name - }, [user.name]) // Only fires when name actually changes -} +useEffect(() => { + console.log(user.id) +}, [user.id]) ``` -**Incorrect (derived object in dependency):** +**For derived state, compute outside effect:** ```tsx -function Chart({ data }) { - const config = { type: 'bar', data } - - useEffect(() => { - renderChart(config) - }, [config]) // New object every render -} +// Incorrect: runs on width=767, 766, 765... +useEffect(() => { + if (width < 768) { + enableMobileMode() + } +}, [width]) + +// Correct: runs only on boolean transition +const isMobile = width < 768 +useEffect(() => { + if (isMobile) { + enableMobileMode() + } +}, [isMobile]) ``` - -**Correct (memoize or use primitives):** - -```tsx -function Chart({ data }) { - useEffect(() => { - renderChart({ type: 'bar', data }) - }, [data]) -} -``` - -Reference: [Removing unnecessary dependencies](https://react.dev/learn/removing-effect-dependencies) diff --git a/.agents/skills/review-react/rules/rerender-derived-state-no-effect.md b/.agents/skills/review-react/rules/rerender-derived-state-no-effect.md index 04f9e9f31..3d9fe4050 100644 --- a/.agents/skills/review-react/rules/rerender-derived-state-no-effect.md +++ b/.agents/skills/review-react/rules/rerender-derived-state-no-effect.md @@ -7,8 +7,6 @@ tags: rerender, derived-state, useEffect, state ## Calculate Derived State During Rendering -**Impact: MEDIUM (avoids redundant renders and state drift)** - If a value can be computed from current props/state, do not store it in state or update it in an effect. Derive it during render to avoid extra renders and state drift. Do not set state in effects solely in response to prop changes; prefer derived values or keyed resets instead. **Incorrect (redundant state and effect):** @@ -39,4 +37,4 @@ function Form() { } ``` -Reference: [You Might Not Need an Effect](https://react.dev/learn/you-might-not-need-an-effect) +References: [You Might Not Need an Effect](https://react.dev/learn/you-might-not-need-an-effect) diff --git a/.agents/skills/review-react/rules/rerender-derived-state.md b/.agents/skills/review-react/rules/rerender-derived-state.md index d61cb8c2f..e5c899f6c 100644 --- a/.agents/skills/review-react/rules/rerender-derived-state.md +++ b/.agents/skills/review-react/rules/rerender-derived-state.md @@ -1,36 +1,29 @@ --- -title: Subscribe to Derived Booleans, Not Raw Objects +title: Subscribe to Derived State impact: MEDIUM -impactDescription: reduces re-renders by narrowing subscription scope -tags: rerender, derived-state, selectors, context +impactDescription: reduces re-render frequency +tags: rerender, derived-state, media-query, optimization --- -## Subscribe to Derived Booleans, Not Raw Objects +## Subscribe to Derived State -**Impact: MEDIUM (reduces re-renders by narrowing subscription scope)** +Subscribe to derived boolean state instead of continuous values to reduce re-render frequency. -When a component only needs to know whether a condition is true (e.g., "is the list empty?"), subscribing to the full object causes re-renders whenever any part of the object changes. Derive a boolean or primitive and subscribe to that instead. - -**Incorrect (subscribing to full array to check emptiness):** +**Incorrect (re-renders on every pixel change):** ```tsx -function EmptyBanner() { - const items = useStore(state => state.items) // Re-renders on any item change - if (items.length > 0) return null - return

No items yet

+function Sidebar() { + const width = useWindowWidth() // updates continuously + const isMobile = width < 768 + return