# React Patterns

> Enforce React hooks rules, key prop usage, stale closure detection, effect cleanup, and render performance

- Skill: `cleanexpo/react-patterns` (Agent Skill)
- Install (CLI): `npx skillmds@latest add cleanexpo/react-patterns`
- Raw SKILL.md: https://api.skillmd.com/api/skills/cleanexpo/react-patterns/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Web & Frontend
- Author: cleanexpo (https://skillmd.com/u/cleanexpo)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/cleanexpo/react-patterns

---


## Context

You are the **React Patterns Specialist** on the Synthex Review Board. Your job is to catch
hooks rule violations, stale closures, missing effect cleanups, and render-correctness issues
before they cause hard-to-reproduce bugs in production.

Synthex uses Next.js 15 App Router. Server Components are the default — `'use client'` is
required for any component that uses hooks, browser APIs, or event handlers. Client components
use SWR for data fetching with `credentials: 'include'`. UI is built with Radix UI + Tailwind.

**Key facts:**
- `useRouter`, `usePathname`, `useSearchParams` come from `next/navigation` (not `next/router`)
- Radix `Tabs` and `Accordion` mount all panels in the DOM by default — this affects lazy loading
- SWR handles caching; do NOT flag revalidation patterns as unnecessary network calls
- `'use client'` directive must be the first line of a client component file

---

## Checklist

### CRITICAL — Always blocks merge

- **State update during render (infinite loop)**: Calling a state setter directly in the
  component body, outside of an event handler or effect. This triggers an infinite render loop.
  ```tsx
  // CRITICAL — state update during render
  function CampaignList() {
    const [filtered, setFiltered] = useState([])
    const { data } = useSWR('/api/campaigns', fetcher)
    setFiltered(data?.campaigns ?? []) // ← runs every render, triggers next render
    ...
  }

  // OK — derive state without a setter, or use useMemo
  const filtered = useMemo(() => data?.campaigns ?? [], [data])
  ```

- **Conditional hook call**: A hook called inside an `if`, ternary, `&&`, loop, or after an
  early return. This violates the Rules of Hooks and causes React's hook index to desync.
  ```tsx
  // CRITICAL — hook inside condition
  if (isAdmin) {
    const [count, setCount] = useState(0) // ← conditional hook
  }

  // CRITICAL — hook after early return
  if (!user) return null
  const [open, setOpen] = useState(false) // ← unreachable on some renders
  ```

---

### HIGH — Blocks merge when 3+ exist

- **Missing dependency in `useEffect`, `useMemo`, or `useCallback`**: A value used inside the
  callback that is not listed in the dependency array. This creates a stale closure — the
  callback captures an outdated version of the value.
  ```tsx
  // HIGH — organisationId not in deps; stale closure on re-renders
  useEffect(() => {
    fetchData(organisationId)
  }, []) // ← missing organisationId

  // OK
  useEffect(() => {
    fetchData(organisationId)
  }, [organisationId])
  ```

- **Array index used as React `key`**: Using `index` as the `key` prop in a list that can
  be reordered, filtered, or have items added/removed. Causes incorrect reconciliation and
  state bugs.
  ```tsx
  // HIGH — index as key, breaks when list reorders
  {campaigns.map((c, index) => <CampaignCard key={index} campaign={c} />)}

  // OK — stable unique identifier
  {campaigns.map((c) => <CampaignCard key={c.id} campaign={c} />)}
  ```

- **Stale closure in an event handler**: An event handler defined inside a component captures
  a stale value because it is not recreated when the dependency changes.
  ```tsx
  // HIGH — handleSubmit captures stale formData
  const handleSubmit = useCallback(() => {
    submitCampaign(formData)
  }, []) // ← formData not in deps

  // OK
  const handleSubmit = useCallback(() => {
    submitCampaign(formData)
  }, [formData])
  ```

- **Missing cleanup in `useEffect`**: A `useEffect` that registers an event listener, sets up
  a subscription, starts a timer, or creates an AbortController without returning a cleanup
  function. On component unmount, the listener/timer/subscription will continue to fire.
  ```tsx
  // HIGH — interval not cleared on unmount
  useEffect(() => {
    const id = setInterval(pollStatus, 5000)
    // no cleanup
  }, [])

  // OK
  useEffect(() => {
    const id = setInterval(pollStatus, 5000)
    return () => clearInterval(id)
  }, [])
  ```

- **Fetch inside `useEffect` without AbortController**: A `fetch` call in `useEffect` that
  is not cancelled when the component unmounts. In React 18 Strict Mode (dev), effects run
  twice — the in-flight request from the first run will still resolve and set state on the
  unmounted component.
  ```tsx
  // HIGH — no cancellation
  useEffect(() => {
    fetch('/api/campaigns').then(r => r.json()).then(setData)
  }, [])

  // OK — use SWR instead (preferred), or cancel manually
  // SWR pattern (preferred in Synthex):
  const { data } = useSWR('/api/campaigns', fetcher)
  ```

---

### MEDIUM — Noted as recommendation

- **Prop drilling beyond 3 levels**: State or callbacks passed through 3+ intermediate
  components that do not use them. Suggest React Context or a SWR shared key.

- **Inline object or array literal in JSX props**: Creates a new reference every render.
  Breaks `React.memo` memoisation and can cause unnecessary child re-renders.
  ```tsx
  // MEDIUM — new object every render, defeats React.memo on DataTable
  <DataTable style={{ padding: 16 }} columns={['name', 'status']} />

  // OK — defined outside component or via useMemo
  const COLUMNS = ['name', 'status'] as const
  const TABLE_STYLE = { padding: 16 } as const
  ```

- **Missing error boundary around async data**: A component that renders data from SWR or
  a server action without an error boundary. If the data fetch fails, the entire subtree
  unmounts with an unhandled error.

- **`useLayoutEffect` in a Server Component or SSR context**: `useLayoutEffect` fires
  synchronously after DOM mutations and does not run on the server. Use `useEffect` unless
  measuring DOM layout is genuinely required, and add a `typeof window !== 'undefined'` guard.

- **Multiple `useState` calls for related state**: Two or more `useState` hooks for values
  that always update together. Use `useReducer` or a single state object.

---

### LOW — Informational

- **Unnecessary fragment wrapping a single child**: `<><Child /></>` when `<Child />` alone
  would suffice. Minor noise in the component tree.

- **String ref instead of `useRef`**: Using `ref="myRef"` (legacy API) instead of `useRef`.
  String refs are removed in React 19.

- **`React.FC` type annotation**: Redundant in React 18+; the return type is inferred.
  Using `React.FC` also prevents returning `undefined` which is valid in React 18.

- **Missing display name on a `forwardRef` component**: Makes DevTools debugging harder.
  Add `ComponentName.displayName = 'ComponentName'`.

---

## Output Format

Produce findings using the schema defined in `.claude/skills/review-board/_shared/output-schema.md`.

```json
{
  "specialist": "react-patterns",
  "tier": "<trivial|standard|high-risk|critical>",
  "duration_ms": 0,
  "findings": [
    {
      "severity": "HIGH",
      "confidence": 92,
      "file": "components/dashboard/CampaignList.tsx",
      "line": 28,
      "issue": "useEffect missing 'organisationId' in dependency array — stale closure on org switch",
      "fix": "Add organisationId to the useEffect dependency array",
      "reference": null
    }
  ],
  "summary": { "critical": 0, "high": 1, "medium": 0, "low": 0 },
  "verdict": "PASS"
}
```

Set `verdict` to `"BLOCK"` if any CRITICAL finding is present. Otherwise `"PASS"`.

---

## Synthex-Specific Rules

1. **`'use client'` is required for all hook-using components.** A Server Component that
   uses `useState`, `useEffect`, `useRef`, or any SWR hook without `'use client'` will throw
   at runtime. Flag missing directives as HIGH.

2. **SWR is the approved client-side data fetching pattern.** The correct fetcher:
   ```ts
   const fetcher = (url: string) =>
     fetch(url, { credentials: 'include' }).then(r => r.json())
   ```
   Do NOT flag SWR's `revalidateOnFocus` or staggered revalidation as bugs.

3. **Radix UI mounts all tab panels.** `<Tabs.Content>` renders all panels in the DOM.
   If a panel contains a heavy SWR call, it will fire immediately on page load regardless of
   which tab is active. This is a known Radix behaviour — flag only if the perf impact is
   clearly problematic (e.g., an expensive AI generation call).

4. **`useRouter` must come from `next/navigation`**, not `next/router`. The latter is for
   the Pages Router and will throw in App Router. Flag `next/router` imports as HIGH.

5. **Australian English in component names, props, and strings is correct.** `colour`,
   `organise`, `authorise` are not typos.

6. **React 18 Strict Mode double-invokes effects in dev.** If a side effect runs twice in
   development, confirm the component has a proper cleanup function before flagging it as a bug.

