Enforces React naming conventions, component structure, readability, and test-first development. Use when creating, reviewing, or refactoring React components. For state management and composition patterns, install the companion skills listed below.
Naming, structure, testing, and readability for React components.
This skill covers naming, structure, testing, and readability. State management, hooks discipline, composition patterns, and UI components are handled by companion skills. Install them alongside this one — the agent will pick them up automatically based on task context.
# State & hooks (57 rules — derived state, useEffect misuse, waterfalls, bundle size) npx skills add vercel-labs/agent-skills --skill vercel-react-best-practices # Composition (compound components, avoid boolean prop sprawl, state lifting) npx skills add vercel-labs/agent-skills --skill vercel-composition-patterns # State management decision framework (Zustand vs RTK vs React Query vs Jotai) npx skills add wshobson/agents --skill react-state-management # TDD workflow npx skills add obra/superpowers --skill test-driven-development # shadcn/ui (Radix + Tailwind component patterns, forms w/ RHF + Zod) npx skills add giuseppe-trisciuoglio/developer-kit --skill shadcn-ui # Tailwind v4 + shadcn setup npx skills add jezweb/claude-skills --skill tailwind-v4-shadcn
Identify test cases BEFORE writing implementation. Co-locate tests next to components.
Button/
├── Button.tsx
├── Button.test.tsx
└── index.ts
role, label, text — never by classname or test ID unless unavoidable.it() block.it('disables submit when form has validation errors') not it('works').// ✅
it('calls onSearch with trimmed query on submit', async () => {
const onSearch = vi.fn();
render(<SearchInput onSearch={onSearch} />);
await userEvent.type(screen.getByRole('searchbox'), ' react hooks ');
await userEvent.keyboard('{Enter}');
expect(onSearch).toHaveBeenCalledWith('react hooks');
});
// ❌
it('works', () => {
const { container } = render(<SearchInput onSearch={vi.fn()} />);
expect(container.querySelector('.search-input')).toBeTruthy();
});
Every name should answer: "What is this and what does it do?"
| ✅ | ❌ | Why |
|---|---|---|
InvoiceTable | Table1 | Describes what it renders |
UserAvatarDropdown | AvatarDD | No abbreviations |
PaymentMethodForm | Form | Too generic |
is, has, should, can prefixon prefix (component API) / handle prefix (internal)render prefix or Content suffix// ✅
interface ProductCardProps {
product: Product;
isOnSale: boolean;
hasReviews: boolean;
onAddToCart: (id: string) => void;
renderBadge?: (product: Product) => ReactNode;
}
// ❌
interface ProductCardProps {
data: any;
sale: boolean;
click: () => void;
}
use + capability description| ✅ | ❌ |
|---|---|
useDebounce(value, delay) | useDb(v, d) |
usePaginatedProducts(filters) | useData(filters) |
handle + noun + verb → handleFormSubmit, handleRowClickisLoading, hasError, shouldRefetchformatPrice, parseQueryParams, toSlugusers, cartItems, selectedIdsAll files use kebab-case. No exceptions. Consistency aids grep and discovery across the codebase.
| Type | Convention | Example |
|---|---|---|
| Components | kebab-case.tsx | order-list-table.tsx |
| Hooks | use-*.ts | use-order-table-query.ts |
| Utils | kebab-case.ts | format-currency.ts |
| Constants | kebab-case.ts | route-paths.ts |
| Types | kebab-case.ts | order-types.ts |
| API layer | *.requests.ts | orders.requests.ts |
| API hooks | *.hooks.ts | orders.hooks.ts |
// 1. Types (if co-located)
interface WidgetProps { ... }
// 2. Constants scoped to component
const TREND_ICONS = { up: TrendUp, down: TrendDown } as const;
// 3. Component
export function Widget({ title, metric, trend }: WidgetProps) {
// 3a. Hooks (top, consistent order)
const [isExpanded, setIsExpanded] = useState(false);
const formatted = useMemo(() => formatNumber(metric), [metric]);
// 3b. Derived values
const TrendIcon = TREND_ICONS[trend];
// 3c. Handlers
const handleToggle = () => setIsExpanded(prev => !prev);
// 3d. Early returns (guards)
if (!metric) return <EmptyState title={title} />;
// 3e. Render
return ( ... );
}
// ❌ Ternary hell
{isLoading ? <Spinner /> : error ? <Error /> : data?.length ? data.map(...) : <Empty />}
// ✅ Guard clauses
if (isLoading) return <Spinner />;
if (error) return <ErrorMessage message={error} />;
if (!data?.length) return <EmptyState />;
return <div>{data.map(...)}</div>;
Scale to project size. Don't over-architect small projects.
Small:
src/
├── components/
├── hooks/
├── utils/
├── types.ts
└── App.tsx
Medium/Large — Route-colocated architecture:
src/
├── components/ # Shared/reusable UI only
│ ├── data-table/ # One concern per file — never mix unrelated components
│ ├── project-filter/
│ └── ...
├── routes/
│ └── orders/
│ └── order-list/
│ ├── order-list.tsx # Page — orchestration only (~60-80 LOC)
│ ├── hooks/
│ │ └── use-order-table-query.ts
│ ├── components/
│ │ └── order-list-table.tsx
│ └── index.ts
├── hooks/ # Shared hooks only
├── lib/
│ └── client/
│ └── orders.requests.ts # Resource-scoped API layer
├── providers/
│ └── router/
│ └── route-map.tsx # Lazy route modules
├── utils/
├── types/
└── constants/
A page file wires together hooks and components. It should not contain table configs, query logic, or form schemas.
// ✅ ~60 LOC — orchestration only
export function OrderList() {
const { data, isLoading } = useOrderTableQuery();
const filters = useOrderFilters();
if (isLoading) return <PageSkeleton />;
return (
<PageLayout title="Orders">
<OrderFilters {...filters} />
<OrderListTable data={data} />
</PageLayout>
);
}
// ❌ 400+ LOC page with inline table columns, filter logic, query params
Target ~60 LOC avg per file. Hard ceiling of 200 LOC — if you hit it, split.
| LOC | Action |
|---|---|
| < 100 | Good |
| 100–200 | Review — can a hook or sub-component be extracted? |
| 200+ | Must split. Extract hooks/, components/, or utils/ next to the file. |
Always use lazy imports in the router. Static imports bundle every page upfront.
// ✅
{ path: "/orders", lazy: () => import("@/routes/orders/order-list") }
// ❌
import { OrderList } from "@/routes/orders/order-list";
{ path: "/orders", element: <OrderList /> }
index.ts) expose only the public API of that feature.lib/index.ts re-exporting everything) — they hurt tree-shaking and make implicit dependencies invisible.lib/.// ✅ Direct import
import { formatOrderDate } from "@/routes/orders/utils/format-order-date";
// ❌ Broad barrel
import { formatOrderDate } from "@/lib";
These are patterns found in production codebases that hurt readability and reviewability.
God files with mixed concerns
A 941-line data-table.tsx that contains both ProjectFilter and an unrelated DataTable. Split them — one concern per file, always.
Fat providers
A 460-line auth-provider.tsx doing data fetching, state management, and UI logic. Break into: use-auth.ts (hook) + auth-context.tsx (context) + auth-guard.tsx (UI boundary).
Flat route dumping
All pages in routes/pages/ with shared logic in routes/components/ means every page implicitly depends on the entire shared folder. Use route-colocated hooks/ and components/ instead.
Broad barrels hiding dependencies
A root lib/index.ts re-exporting everything makes it impossible to see what a feature actually depends on. Import directly from the source file.
Static router imports
Importing every page at the top of router.tsx bundles everything upfront. Use lazy: () => import(...) per route.
Every component must have:
<button> for actions, <a> for nav — never <div onClick>aria-label on icon buttons, aria-live for dynamic updates, aria-expanded for togglesany typesconsole.log