React, Next.js, React Native, and web design best practices from Vercel Engineering. Use when writing, reviewing, or refactoring React/Next.js/React Native components, optimizing performance, auditing UI accessibility, or designing component APIs. Triggers: "review react", "optimize component", "check accessibility", "react best practices", "component architecture", "react native performance".
Scanned 9/11/2026
Install to Claude Code
npx -y skills add grahama1970/agent-skills --skill best-practices-react --agent claude-codeInstalls into .claude/skills of the current project.
Are you the author of Best Practices React?
Add the live security badge to your README — it updates automatically with every re-scan.
[](https://www.skillsdirectory.com/skills/grahama1970-best-practices-react)More formats (shields.io, HTML) on the badges page.
---
name: best-practices-react
description: |
React, Next.js, React Native, and web design best practices from Vercel Engineering.
Use when writing, reviewing, or refactoring React/Next.js/React Native components,
optimizing performance, auditing UI accessibility, or designing component APIs.
Triggers: "review react", "optimize component", "check accessibility", "react best practices",
"component architecture", "react native performance".
triggers:
- best practices react
- react review
- react component
- next.js
- react native
- component architecture
license: MIT
metadata:
author: vercel
version: "1.0.0"
language: typescript
provides:
- best-practices-react
composes:
- task-monitor
- agentic-evals
disciplines:
- engineering-standards
- ui-design-engineering
---
# React Best Practices Collection
Enterprise-grade React/Next.js/React Native rules from Vercel Engineering. 100+ rules across 4 sub-skills with impact-prioritized categories.
## Sub-Skills
| Sub-Skill | Rules | Focus |
|-----------|-------|-------|
| `react-best-practices` | 40+ | React/Next.js performance optimization |
| `web-design-guidelines` | 100+ | Accessibility, forms, animation, dark mode, i18n |
| `react-native-skills` | 16 | Mobile performance, animations, platform APIs |
| `composition-patterns` | 8+ | Component architecture, prop design, compound components |
## When to Use
- **react-best-practices**: Writing React components, Next.js pages, data fetching, bundle optimization
- **web-design-guidelines**: UI review, accessibility audit, design compliance, UX check
- **react-native-skills**: React Native/Expo apps, mobile performance, native modules
- **composition-patterns**: Refactoring boolean prop proliferation, building component libraries
## Quick Reference
### Critical Impact — NON-NEGOTIABLE
Every interactive element gets **4 things at write time** — no exceptions, no retrofitting:
1. **`data-qid`** — stable automation/test identifier consumed only through `[data-qid='...']`. Format: `component:element:qualifier` (colon-separated). Used by test manifests, CDP automation, and `scripts/verify-data-qid.py` enforcement.
2. **`data-qs-action`** — QuerySpec action ID for agent/voice execution. Format: `COMPONENT_ACTION` (uppercase, underscore). An agent resolves NL intent → action ID → `document.querySelector('[data-qs-action="APPROVE_ENTRY"]').click()` — zero latency, no database lookup, works offline.
3. **`title`** — human-readable label. Required for MIL-STD-1472H compliance, screen readers, tooltips.
4. **`useRegisterAction`** — registers the action to ArangoDB `app_actions` collection for training data, analytics, and cross-app action discovery.
Components without all 4 are **not shippable**. `scripts/verify-data-qid.py` enforces source-level `data-qid` coverage in CI.
**Write-time checklist:**
```tsx
<button
data-qid="quarantine:action:approve"
data-qs-action="QUARANTINE_APPROVE"
title="Approve selected entry"
onClick={() => handleAction(id, 'approve')}
>
Approve
</button>
// In component body (registers to ArangoDB app_actions for training flywheel):
useRegisterAction('quarantine:action:approve', {
app: 'datalake-explorer',
action: 'QUARANTINE_APPROVE',
label: 'Approve',
description: 'Approve quarantined entry and remove from queue',
})
```
**Why both `data-qs-action` AND `useRegisterAction`?**
- `data-qs-action` = runtime DOM introspection (agent clicks the element, zero latency)
- `useRegisterAction` = ArangoDB registry (training data, cross-app discovery, analytics)
- They use the SAME action ID (`QUARANTINE_APPROVE`) so intent → action → DOM click is one straight line
**Enforcement**: `scripts/verify-data-qid.py` MUST run in CI and `/plan` DoD for any UX task. Exit 1 = not shippable. `/review-plan` MUST FAIL any UX plan without it.
**QID selector contract:** executable interaction tests use only
`[data-qid='...']` selectors. No `#id`, `.class`, text, XPath, `nth-child`, array
position, render-order, or other CSS-selector fallback is acceptable. A missing
or unstable QID is an instrumentation defect to fix in the component, not a test
authoring problem to guess around.
Every interactive control must expose a `data-qid` that is unique within every
reachable live-DOM state.
**Static vs live boundary:** `scripts/verify-data-qid.py` checks source-level
facts it can prove: interactive JSX controls have a `data-qid`, literal QIDs use
the canonical shape, repeated-entity QIDs do not derive from volatile identity,
and obvious duplicate literal QIDs are rejected. It does **not** prove every
conditionally rendered live-DOM state is reachable or unique. Live reachability,
live uniqueness, missing rendered QIDs, and duplicate rendered QIDs remain owned
by `skills/test-interactions/run.sh discover`.
**Persistent controls:** use invariant semantic QIDs such as
`component:element:qualifier`.
**Repeated entity controls:** append a stable domain or test identity when
needed, for example `orders:item:open:${order.id}`. Do not append array index,
render position, sort order, `Date.now()`, timestamps, random values,
per-render UUIDs, or `nth-child`.
### File size — capped by contents, not one number
A file with logic (components, hooks, helpers) may not exceed **400 lines**. A
file of pure data (style maps, token tables, fixtures) may not exceed **800**.
Do not reuse a Python line limit here. JSX is verbally bulky — one element with
`data-qid`, `data-qs-action`, `title`, `onClick` and `style` costs 6–8 lines — so
800 lines of TSX holds far less logic and far more branching than 800 of Python.
The distinction matters more than either number: a 795-line style map is
reviewable by scanning and has no control flow; a 500-line component means
branching and state, which is the blast-radius problem this skill already
describes. A ceiling only enforces modularity if it fires — measured over a
99-file React surface, 800 flagged 2 files, 400 flagged the ones worth splitting.
```bash
python3 scripts/verify-file-size.py src/ # exit 1 on violation
```
Data files are **detected, not declared**, so the lower ceiling cannot be dodged
by renaming. Existing violations go in `.file-size-allowlist` as `path: lines` —
a debt marker that pins the current size, so an allowlisted file may not grow.
Runs in CI and `/plan` DoD alongside `scripts/verify-data-qid.py`.
- Eliminate request waterfalls (parallel fetching, `Promise.all`)
- Bundle size (dynamic imports, tree shaking, barrel file avoidance)
- Accessibility (aria-labels, semantic HTML, keyboard navigation)
- FlashList over FlatList (React Native)
### High Impact
- Server-side rendering and streaming
- Focus states (`focus-visible` patterns)
- Layout (flex patterns, safe areas)
- Animation (Reanimated, `prefers-reduced-motion`)
### Medium Impact
- Re-render optimization (`useMemo`, `useCallback`, React Compiler)
- Forms (autocomplete, validation, error handling)
- Dark mode (`color-scheme`, `theme-color` meta)
- State management (Zustand patterns)
## File Structure
```
best-practices-react/
├── SKILL.md # This file
├── README.md # Detailed sub-skill descriptions
├── CLAUDE.md # Agent guidance (skill creation)
├── skills/
│ ├── react-best-practices/ # React/Next.js performance rules
│ │ ├── SKILL.md
│ │ └── rules/ # Atomic rule files
│ ├── web-design-guidelines/ # UI/UX/a11y rules
│ │ ├── SKILL.md
│ │ └── rules/
│ ├── react-native-skills/ # React Native/Expo rules
│ │ ├── SKILL.md
│ │ └── rules/
│ ├── composition-patterns/ # Component architecture rules
│ │ ├── SKILL.md
│ │ └── rules/
│ └── claude.ai/
│ └── vercel-deploy-claimable/ # Vercel deployment skill
└── packages/
└── react-best-practices-build/ # TypeScript build tooling
```
## Common Mistakes
### WRONG: `useRegisterAction` inside JSX, `.map()`, or function params
```tsx
// BROKEN — hook inside .map() callback (violates Rules of Hooks)
{items.map((item) => {
useRegisterAction('list:item', { ... }) // ← CRASH or silent failure
return <div>{item.name}</div>
})}
// BROKEN — hook inside function parameter destructuring
function MetaItem({
useRegisterAction('meta:item', { ... }) // ← syntax error
label,
value,
}: Props) { ... }
```
### RIGHT: `useRegisterAction` at top of component function body
```tsx
export default function QuarantineView({ entries }: Props) {
// Hooks FIRST — before any other code
useRegisterAction('quarantine:action:approve', { ... })
useRegisterAction('quarantine:action:reject', { ... })
const [selected, setSelected] = useState(null)
// ... rest of component
```
**Why:** React hooks must be called at the top level of a function component. Mechanical instrumentation scripts that inject hooks by line number will place them inside JSX or callbacks. Always verify placement manually.
### WRONG: Import paths verified only by `tsc --noEmit`
```bash
npx tsc --noEmit # exit 0 — "TypeScript clean!"
# But Vite dev server shows: Failed to resolve import "../../../hooks/useRegisterAction"
```
### RIGHT: Verify imports against the live Vite dev server
```bash
# TSC and Vite resolve paths differently. Always check Vite:
curl -s -o /dev/null -w "%{http_code}" "http://localhost:3002/src/components/MyComponent.tsx"
# 200 = compiles. 500 = broken import. Do this for EVERY modified file.
```
**Why:** `moduleResolution: "bundler"` in tsconfig means TSC skips resolution for some imports. Vite resolves them at serve time and will 500 on wrong relative paths. A file can pass `tsc --noEmit` and still crash in the browser.
### WRONG: Test manifest generated from `grep` of TSX source files
```bash
# Grepping source finds 124 data-qid values. But 91 of them are inside
# modals, dropdowns, and subcomponents that only render after user interaction.
grep -roh 'data-qid="[^"]*"' src/components/ | sort -u # ← 124 qids
# Live DOM on page load has only 33. The other 91 don't exist yet.
```
### RIGHT: Generate test manifest from the live DOM via CDP
```bash
# Use /test-interactions generate, or query the live DOM directly:
./run.sh generate --url "http://localhost:3002/#my-view" --output manifest.json
# Or via CDP JavaScript execution:
# document.querySelectorAll('[data-qid]') on EACH tab/view state
```
**Why:** Components render conditionally. A `data-qid` in TSX source only exists in the DOM when that component is mounted. Test manifests must reflect what the user can actually see and click, not what exists in source code.
### WRONG: Rebuilding a chat/control well inside a large page monolith
```tsx
// One 2,000-line route component owns the grid, data cards, alerts, voice UI,
// chat well, prompt copy, timers, command registry, and distance-mode branches.
function KioskDistanceView() {
return (
<>
<MetricGrid />
<aside>{/* chat well markup and state inline */}</aside>
</>
)
}
```
This makes a small chat revert dangerous. A request such as "put the orb back"
can accidentally mutate grid layout, alert cards, distance-mode headers, or
other unrelated UI because all concerns share one file and one style object.
### RIGHT: Extract volatile wells before non-trivial UX iteration
```tsx
function KioskDistanceView() {
return (
<>
<MetricGrid />
<EmbryKioskChatWell
sharedOrbState={sharedOrbState}
commands={commands}
onSelectPage={onSelectPage}
/>
</>
)
}
```
**Why:** Chat wells, voice panels, drawers, artifact inspectors, and other
high-churn control surfaces need their own component boundary before design
iteration. This rule went unenforced for want of a number: one real route
component reached **14,767 lines with 173 top-level declarations**, including a
6,280-line root, because nothing failed until someone read it. See the file-size
ceilings above. Preserve the accepted component or restore it with `git show` /
`git revert` instead of re-bespoking it inside the page. The parent route should
compose the well and pass data/actions; it should not own the well's markup,
prompt copy, timers, visual state machine, and command registry. If the human
asks to revert a chat/control surface, first identify the last known-good
component commit and make the revert path explicit before applying new edits.
### WRONG: Static `data-qid` on dynamic list items
```tsx
// Every entry gets the same qid — selector matches multiple elements
{entries.map(e => (
<div data-qid="quarantine:entry" onClick={() => select(e.id)}>
{e.name}
</div>
))}
```
### RIGHT: Dynamic `data-qid` with stable identifier
```tsx
{entries.map(e => (
<div data-qid={`quarantine:entry:${e.id}`} onClick={() => select(e.id)}>
{e.name}
</div>
))}
```
**Why:** Test manifests need unique selectors. If 18 entries share `data-qid="quarantine:entry"`, `querySelector` always hits the first one. Use the entity ID to make each qid unique. Dynamic list manifests must come from the live DOM and target the rendered element by its `[data-qid='...']` selector. If no stable executable QID exists, fix instrumentation before writing the test.
### WRONG: Early return that hides chrome (filters, nav, controls) on empty state
```tsx
// The entire component disappears — user can't switch filters to find entries
if (!loading && entries.length === 0) {
return <div>Queue is empty</div> // ← filter buttons, layout controls GONE
}
```
### RIGHT: Empty state renders inline inside the content area
```tsx
const showEmptyState = !loading && entries.length === 0
return (
<div>
<FilterBar /> {/* always visible */}
<LayoutControls /> {/* always visible */}
{showEmptyState
? <EmptyMessage>No entries for this filter</EmptyMessage>
: <EntryList entries={filtered} />
}
</div>
)
```
**Why:** Early returns that replace the whole component break interaction tests — filter/layout `data-qid` elements vanish from the DOM when the list is empty. The user also loses the ability to switch filters or change layout. Chrome (nav, filters, controls) must always render; only the content area should show empty state.
### WRONG: Importing from barrel files that bundle the entire module
```typescript
import { Button } from '@/components'; // barrel re-export, bundles everything
```
### RIGHT: Import directly from the component file
```typescript
import { Button } from '@/components/Button';
```
### WRONG: Missing focus-visible styles on interactive elements
```css
button:focus { outline: 2px solid blue; } /* triggers on click too */
```
### RIGHT: Use focus-visible for keyboard-only focus indicators
```css
button:focus-visible { outline: 2px solid blue; }
```
### WRONG: Sequential data fetching (request waterfall)
```typescript
const user = await fetchUser(id);
const posts = await fetchPosts(user.id); // waits for user first
```
### RIGHT: Parallel fetching with Promise.all
```typescript
const [user, posts] = await Promise.all([fetchUser(id), fetchPosts(id)]);
```
Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.
No comments yet. Be the first to comment!