react-web について
フロントエンド開発向けのReact Webスキルです。テストを先に書く手順と、コンポーネント、状態、ルーティング、スタイル、検証のパターンを示し、ユーザー向けフローの視覚確認も扱います。
主な機能
- 01テスト先行のコンポーネント開発
- 02関数コンポーネントとフックのパターン
- 03状態管理とルーティングの指針
- 04フォームとスタイルの規約
- 05動作と視覚の検証
活用シーン
- 01テストを先に書いてReactコンポーネントを開発
- 02フックとアプリ状態の構成
- 03フォームとルートの実装
- 04ユーザー向けフローの検証
インストール
コマンドをコピーしてターミナルで実行します。
$
npx skills add https:/ / github.com/ alinaqi/ maggy --skill react-webインストール後、Claude Code で/react-webを入力して読み込まれたか確認します。
詳細
React Web スキル
テストファースト開発(必須)
重要:実装コードを書く前に、必ずテストを書いてください。フロントエンドコンポーネントでは譲れないルールです。
TFD ワークフロー
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
コンポーネントの開発順序
# CORRECT ORDER - Test first
1. Create Button.test.tsx # Write tests for expected behavior
2. Run tests (they fail) # npm test -- Button
3. Create Button.tsx # Implement to pass tests
4. Run tests (they pass) # Verify implementation
5. Create Button.module.css # Style after logic works
# WRONG ORDER - Never do this
1. Create Button.tsx # ❌ No tests exist yet
2. Create Button.module.css # ❌ Still no tests
3. "I'll add tests later" # ❌ Tests never get written
テストファイルの構成(最初に作成)
// 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(<Button label="Click me" onClick={() => {}} />);
expect(screen.getByRole('button', { name: 'Click me' })).toBeInTheDocument();
});
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(<Button label="Click me" onClick={onClick} />);
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(<Button label="Click" onClick={() => {}} disabled />);
expect(screen.getByRole('button')).toHaveAttribute('aria-disabled', 'true');
});
});
});
フックのテストファーストパターン
// 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);
});
});
遵守チェックリスト
コンポーネント/フックの実装を書く前に、必ず以下を確認してください。
- テストファイルが存在する:
Component.test.tsx - 想定される動作すべてにテストケースがある
- テストを実行すると失敗する(テストが有効であることを確認)
- その後にのみ、実装ファイルを作成する
テストが省略された場合、Claude は必ず以下を実行してください。
⚠️ 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/
├── 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
コンポーネントのパターン
関数コンポーネントのみを使用する
// 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 (
<button
className={styles[variant]}
onClick={onClick}
disabled={disabled}
>
{label}
</button>
);
}
ロジックをフックに切り出す
// useHome.ts - all logic here
export function useHome() {
const [items, setItems] = useState<Item[]>([]);
const [loading, setLoading] = useState(false);
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 <Spinner />;
return <ItemList items={items} onRefresh={refresh} />;
}
Props のインターフェースを必ず明示する
// Always define props interface, even if simple
interface ItemCardProps {
item: Item;
onClick: (id: string) => void;
}
export function ItemCard({ item, onClick }: ItemCardProps): JSX.Element {
return (
<div onClick={() => onClick(item.id)}>
<h3>{item.title}</h3>
</div>
);
}
状態管理
まずはローカル状態を使う
// Start with useState, escalate only when needed
const [value, setValue] = useState('');
グローバル状態には Zustand を使う(必要な場合)
// 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<AppState>((set) => ({
user: null,
theme: 'light',
setUser: (user) => set({ user }),
toggleTheme: () => set((state) => ({
theme: state.theme === 'light' ? 'dark' : 'light'
})),
}));
サーバー状態には React Query を使う
// 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'] });
},
});
}
ルーティング
React Router(Vite/CRA)
// App.tsx
import { BrowserRouter, Routes, Route } from 'react-router-dom';
export function App(): JSX.Element {
return (
<BrowserRouter>
<Routes>
<Route path="/" element={<HomePage />} />
<Route path="/items/:id" element={<ItemPage />} />
<Route path="*" element={<NotFoundPage />} />
</Routes>
</BrowserRouter>
);
}
保護されたルート
interface ProtectedRouteProps {
children: JSX.Element;
}
function ProtectedRoute({ children }: ProtectedRouteProps): JSX.Element {
const { user } = useAppStore();
const location = useLocation();
if (!user) {
return <Navigate to="/login" state={{ from: location }} replace />;
}
return children;
}
スタイリング
CSS Modules(推奨)
// 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';
<button className={styles.primary}>Click</button>
Tailwind(代替)
// 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;
<button className={buttonVariants[variant]}>{label}</button>
フォーム
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<typeof schema>;
export function LoginForm(): JSX.Element {
const { register, handleSubmit, formState: { errors } } = useForm<FormData>({
resolver: zodResolver(schema),
});
const onSubmit = (data: FormData) => {
// handle submit
};
return (
<form onSubmit={handleSubmit(onSubmit)}>
<input {...register('email')} />
{errors.email && <span>{errors.email.message}</span>}
<input type="password" {...register('password')} />
{errors.password && <span>{errors.password.message}</span>}
<button type="submit">Login</button>
</form>
);
}
テスト
React Testing Library によるコンポーネントのテスト
import { render, screen, fireEvent } from '@testing-library/react';
import { Button } from './Button';
describe('Button', () => {
it('calls onClick when clicked', () => {
const onClick = vi.fn();
render(<Button label="Click me" onClick={onClick} />);
fireEvent.click(screen.getByText('Click me'));
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.getByText('Click me'));
expect(onClick).not.toHaveBeenCalled();
});
it('applies correct variant class', () => {
render(<Button label="Click" onClick={() => {}} variant="secondary" />);
expect(screen.getByRole('button')).toHaveClass('secondary');
});
});
フックのテスト
import { renderHook, act, waitFor } from '@testing-library/react';
import { useCounter } from './useCounter';
describe('useCounter', () => {
it('increments counter', () => {
const { result } = renderHook(() => useCounter());
act(() => {
result.current.increment();
});
expect(result.current.count).toBe(1);
});
});
Playwright による E2E テスト
// tests/e2e/login.spec.ts
import { test, expect } from '@playwright/test';
test('user can login', async ({ page }) => {
await page.goto('/login');
await page.fill('[name="email"]', '[email protected]');
await page.fill('[name="password"]', 'password123');
await page.click('button[type="submit"]');
await expect(page).toHaveURL('/dashboard');
await expect(page.getByText('Welcome')).toBeVisible();
});
ビジュアル検証(Web ではデフォルト)
動作テストでロジックは検証できますが、UI の見た目や操作の流れが実際に適切かどうかは確認できません。ユーザー向けのフローでは、完了とする前に必ずビジュアル面で最終確認を行ってください。
demo-video(デフォルト) — 実際のフローを記録する字幕付きの Playwright ウォークスルーです。合格する E2E テストも兼ね、関係者向けの証拠となる mp4 を生成します。Web 開発におけるこのハーネスのデフォルトのビジュアル検証方法です。これがなければ、ユーザー向けのフローは「完了」とは言えません(baseの Definition of Done を参照)。visual-validation— ピクセル単位の UI 変更に対する自動スクリーンショット回帰チェックです。
これらを組み合わせることで、playwright-testing は動作を検証し、visual-validation はビジュアル回帰を検出し、demo-video は人が見るとおりの一連のフロー全体を実証します。
パフォーマンス
メモ化
// Memoize expensive components
const ItemList = memo(function ItemList({ items }: ItemListProps) {
return items.map(item => <ItemCard key={item.id} item={item} />);
});
// Memoize callbacks passed to children
const handleClick = useCallback((id: string) => {
setSelectedId(id);
}, []);
// Memoize expensive computations
const sortedItems = useMemo(() => {
return [...items].sort((a, b) => a.name.localeCompare(b.name));
}, [items]);
コード分割
// Lazy load routes
const ItemPage = lazy(() => import('./pages/Item'));
<Suspense fallback={<Spinner />}>
<Route path="/items/:id" element={<ItemPage />} />
</Suspense>
React Web のアンチパターン
- ❌ JSX 内のインライン関数 — useCallback を使う
- ❌ render 内のロジック — フックに切り出す
- ❌ コンポーネントの深いネスト — 階層をフラットにする
- ❌ リストの key にインデックスを使う — 安定した ID を使う
- ❌ state を直接変更する — 必ず setter を使う
- ❌ 2 階層を超える Prop drilling — context または状態管理を使う
- ❌ 派生状態に useEffect を使う — useMemo を使う
- ❌ useEffect でデータを取得する — React Query を使う
- ❌ ビジネスロジックと UI を混在させる — core/ を純粋に保つ
- ❌ 大きなコンポーネント(100 行超) — 小さな単位に分割する
- ❌ JS オブジェクト内の CSS — CSS modules または Tailwind を使う
- ❌ TypeScript のエラーを無視する — 修正する
コメント
Markdown 対応