React Web Skill
Load with: base.md + typescript.md
Test-First Development (MANDATORY)
CRITICAL: Tests MUST be written BEFORE implementation code. This is non-negotiable for frontend components.
The TFD Workflow 1. Write test file first → Defines expected behavior 2. Run test (it fails) → Confirms test is valid 3. Write minimal code → Just enough to pass 4. Run test (it passes) → Validates implementation 5. Refactor if needed → Tests catch regressions
Component Development Order
CORRECT ORDER - Test first
- Create Button.test.tsx # Write tests for expected behavior
- Run tests (they fail) # npm test -- Button
- Create Button.tsx # Implement to pass tests
- Run tests (they pass) # Verify implementation
- Create Button.module.css # Style after logic works
WRONG ORDER - Never do this
- Create Button.tsx # ❌ No tests exist yet
- Create Button.module.css # ❌ Still no tests
- "I'll add tests later" # ❌ Tests never get written
Test File Structure (Create First) // Button.test.tsx - CREATE THIS FIRST import { render, screen, fireEvent } from '@testing-library/react'; import { Button } from './Button';
describe('Button', () => { // Define ALL expected behaviors upfront describe('rendering', () => { it('renders with label', () => { render(
it('applies variant class', () => {
render(<Button label="Click" onClick={() => {}} variant="secondary" />);
expect(screen.getByRole('button')).toHaveClass('secondary');
});
});
describe('interactions', () => { it('calls onClick when clicked', () => { const onClick = vi.fn(); render(); fireEvent.click(screen.getByRole('button')); expect(onClick).toHaveBeenCalledTimes(1); });
it('does not call onClick when disabled', () => {
const onClick = vi.fn();
render(<Button label="Click me" onClick={onClick} disabled />);
fireEvent.click(screen.getByRole('button'));
expect(onClick).not.toHaveBeenCalled();
});
});
describe('accessibility', () => { it('has correct aria attributes when disabled', () => { render(
Hook Test First Pattern // useCounter.test.ts - CREATE THIS FIRST import { renderHook, act } from '@testing-library/react'; import { useCounter } from './useCounter';
describe('useCounter', () => { it('starts at initial value', () => { const { result } = renderHook(() => useCounter(5)); expect(result.current.count).toBe(5); });
it('increments', () => { const { result } = renderHook(() => useCounter()); act(() => result.current.increment()); expect(result.current.count).toBe(1); });
it('decrements', () => { const { result } = renderHook(() => useCounter(5)); act(() => result.current.decrement()); expect(result.current.count).toBe(4); });
it('resets to initial value', () => { const { result } = renderHook(() => useCounter(10)); act(() => result.current.increment()); act(() => result.current.reset()); expect(result.current.count).toBe(10); }); });
Enforcement Checklist
Before writing ANY component/hook implementation:
Test file exists: Component.test.tsx All expected behaviors have test cases Tests run and FAIL (proves tests are valid) Only THEN create implementation file
If tests are skipped, Claude MUST:
⚠️ TEST-FIRST VIOLATION
Cannot create [Component].tsx - no test file exists.
Creating [Component].test.tsx first with tests for: - Rendering with required props - User interactions - Edge cases - Accessibility
Project Structure project/ ├── src/ │ ├── core/ # Pure business logic (no React) │ │ ├── types.ts │ │ └── services/ │ ├── components/ # Reusable UI components │ │ ├── Button/ │ │ │ ├── Button.tsx │ │ │ ├── Button.test.tsx │ │ │ ├── Button.module.css # or .styles.ts │ │ │ └── index.ts │ │ └── index.ts # Barrel export │ ├── pages/ # Route-level components │ │ ├── Home/ │ │ │ ├── HomePage.tsx │ │ │ ├── useHome.ts # Page-specific hook │ │ │ └── index.ts │ │ └── index.ts │ ├── hooks/ # Shared custom hooks │ ├── store/ # State management │ ├── api/ # API client and queries │ ├── utils/ # Utilities │ ├── App.tsx │ └── main.tsx ├── tests/ │ ├── unit/ │ └── e2e/ ├── public/ ├── package.json ├── tsconfig.json ├── vite.config.ts # or next.config.js └── CLAUDE.md
Component Patterns Functional Components Only // Good - simple, testable interface ButtonProps { label: string; onClick: () => void; disabled?: boolean; variant?: 'primary' | 'secondary'; }
export function Button({ label, onClick, disabled = false, variant = 'primary' }: ButtonProps): JSX.Element { return ( ); }
Extract Logic to Hooks
// useHome.ts - all logic here
export function useHome() {
const [items, setItems] = useState
const refresh = useCallback(async () => { setLoading(true); const data = await fetchItems(); setItems(data); setLoading(false); }, []);
useEffect(() => { refresh(); }, [refresh]);
return { items, loading, refresh }; }
// HomePage.tsx - pure presentation export function HomePage(): JSX.Element { const { items, loading, refresh } = useHome();
if (loading) return
return
Props Interface Always Explicit // Always define props interface, even if simple interface ItemCardProps { item: Item; onClick: (id: string) => void; }
export function ItemCard({ item, onClick }: ItemCardProps): JSX.Element { return (
{item.title}
State Management Local State First // Start with useState, escalate only when needed const [value, setValue] = useState('');
Zustand for Global State (if needed) // store/useAppStore.ts import { create } from 'zustand';
interface AppState { user: User | null; theme: 'light' | 'dark'; setUser: (user: User | null) => void; toggleTheme: () => void; }
export const useAppStore = create
React Query for Server State // api/queries/useItems.ts import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { itemsApi } from '../client';
export function useItems() { return useQuery({ queryKey: ['items'], queryFn: itemsApi.getAll, staleTime: 5 * 60 * 1000, // 5 minutes }); }
export function useCreateItem() { const queryClient = useQueryClient();
return useMutation({ mutationFn: itemsApi.create, onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['items'] }); }, }); }
Routing React Router (Vite/CRA) // App.tsx import { BrowserRouter, Routes, Route } from 'react-router-dom';
export function App(): JSX.Element {
return (
Protected Routes interface ProtectedRouteProps { children: JSX.Element; }
function ProtectedRoute({ children }: ProtectedRouteProps): JSX.Element { const { user } = useAppStore(); const location = useLocation();
if (!user) {
return
return children; }
Styling CSS Modules (Preferred) // Button.module.css .primary { background: var(--color-primary); color: white; }
.secondary { background: transparent; border: 1px solid var(--color-primary); }
// Button.tsx import styles from './Button.module.css';
Tailwind (Alternative) // Use consistent patterns, extract repeated combinations const buttonVariants = { primary: 'bg-blue-500 text-white hover:bg-blue-600', secondary: 'bg-transparent border border-blue-500 text-blue-500', } as const;
Forms React Hook Form + Zod import { useForm } from 'react-hook-form'; import { zodResolver } from '@hookform/resolvers/zod'; import { z } from 'zod';
const schema = z.object({ email: z.string().email('Invalid email'), password: z.string().min(8, 'Password must be at least 8 characters'), });
type FormData = z.infer
export function LoginForm(): JSX.Element {
const { register, handleSubmit, formState: { errors } } = useForm
const onSubmit = (data: FormData) => { // handle submit };
return (