フロントエンドビルダースキル
生成規約
コンポーネント構造
code
src/components/ ├── ui/ # 汎用UIコンポーネント(Button, Input等) ├── features/ # 機能単位のコンポーネント ├── layouts/ # レイアウトコンポーネント └── forms/ # フォーム関連コンポーネント
ファイル命名規則
- •コンポーネント: PascalCase(例:
UserProfile.tsx) - •フック: camelCase + use prefix(例:
useUserProfile.ts) - •ユーティリティ: camelCase(例:
formatDate.ts)
必須チェックリスト
品質基準
- • TypeScript strict mode準拠
- • Props型定義は必ず明示
- • デフォルトpropsを設定
- • export default ではなく named export を使用
アクセシビリティ(a11y)
- • aria-label / aria-labelledby を適切に設定
- • role属性を必要に応じて設定
- • キーボード操作対応(tabIndex, onKeyDown)
- • フォーカス管理(focus-visible)
- • カラーコントラスト比 4.5:1以上
パフォーマンス
- • 不要な再レンダリングを防止(memo, useMemo, useCallback)
- • 重いコンポーネントは遅延ロード(dynamic import)
- • 画像は next/image を使用
レスポンシブ
- • モバイルファースト設計
- • Tailwindのブレークポイント活用(sm, md, lg, xl)
コンポーネントテンプレート
基本コンポーネント(shadcn/ui風)
tsx
import { type FC, type ComponentPropsWithoutRef, forwardRef } from 'react';
import { cva, type VariantProps } from 'class-variance-authority';
import { cn } from '@/lib/utils';
const buttonVariants = cva(
// ベーススタイル
'inline-flex items-center justify-center rounded-md font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50',
{
variants: {
variant: {
default: 'bg-primary text-primary-foreground hover:bg-primary/90',
destructive: 'bg-destructive text-destructive-foreground hover:bg-destructive/90',
outline: 'border border-input bg-background hover:bg-accent hover:text-accent-foreground',
secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80',
ghost: 'hover:bg-accent hover:text-accent-foreground',
link: 'text-primary underline-offset-4 hover:underline',
},
size: {
default: 'h-10 px-4 py-2',
sm: 'h-9 rounded-md px-3 text-sm',
lg: 'h-11 rounded-md px-8 text-lg',
icon: 'h-10 w-10',
},
},
defaultVariants: {
variant: 'default',
size: 'default',
},
}
);
interface ButtonProps
extends ComponentPropsWithoutRef<'button'>,
VariantProps<typeof buttonVariants> {
isLoading?: boolean;
}
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, isLoading = false, disabled, children, ...props }, ref) => {
return (
<button
ref={ref}
className={cn(buttonVariants({ variant, size, className }))}
disabled={disabled || isLoading}
aria-busy={isLoading}
{...props}
>
{isLoading ? (
<span className="mr-2 h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent" />
) : null}
{children}
</button>
);
}
);
Button.displayName = 'Button';
フォームコンポーネント(React Hook Form + Zod)
tsx
'use client';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { Button } from '@/components/ui/Button';
const formSchema = z.object({
email: z
.string()
.min(1, 'メールアドレスを入力してください')
.email('有効なメールアドレスを入力してください'),
password: z
.string()
.min(1, 'パスワードを入力してください')
.min(8, 'パスワードは8文字以上で入力してください'),
});
type FormValues = z.infer<typeof formSchema>;
interface LoginFormProps {
onSubmit: (data: FormValues) => Promise<void>;
}
export const LoginForm: FC<LoginFormProps> = ({ onSubmit }) => {
const {
register,
handleSubmit,
formState: { errors, isSubmitting },
} = useForm<FormValues>({
resolver: zodResolver(formSchema),
defaultValues: {
email: '',
password: '',
},
});
return (
<form onSubmit={handleSubmit(onSubmit)} noValidate className="space-y-4">
<div className="space-y-2">
<label htmlFor="email" className="text-sm font-medium">
メールアドレス
</label>
<input
id="email"
type="email"
autoComplete="email"
className={cn(
'flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
errors.email && 'border-destructive'
)}
aria-invalid={!!errors.email}
aria-describedby={errors.email ? 'email-error' : undefined}
{...register('email')}
/>
{errors.email && (
<p id="email-error" role="alert" className="text-sm text-destructive">
{errors.email.message}
</p>
)}
</div>
<div className="space-y-2">
<label htmlFor="password" className="text-sm font-medium">
パスワード
</label>
<input
id="password"
type="password"
autoComplete="current-password"
className={cn(
'flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
errors.password && 'border-destructive'
)}
aria-invalid={!!errors.password}
aria-describedby={errors.password ? 'password-error' : undefined}
{...register('password')}
/>
{errors.password && (
<p id="password-error" role="alert" className="text-sm text-destructive">
{errors.password.message}
</p>
)}
</div>
<Button type="submit" className="w-full" isLoading={isSubmitting}>
{isSubmitting ? 'ログイン中...' : 'ログイン'}
</Button>
</form>
);
};
データ取得コンポーネント(Server Component)
tsx
// app/users/page.tsx
import { Suspense } from 'react';
import { UserList } from '@/components/features/UserList';
import { UserListSkeleton } from '@/components/features/UserListSkeleton';
export default function UsersPage() {
return (
<div className="container mx-auto py-8">
<h1 className="text-2xl font-bold mb-6">ユーザー一覧</h1>
<Suspense fallback={<UserListSkeleton />}>
<UserList />
</Suspense>
</div>
);
}
// components/features/UserList.tsx
async function getUsers() {
const res = await fetch('/api/users', { next: { revalidate: 60 } });
if (!res.ok) throw new Error('Failed to fetch users');
return res.json();
}
export async function UserList() {
const users = await getUsers();
return (
<ul className="space-y-4">
{users.map((user) => (
<li key={user.id} className="p-4 border rounded-lg">
<p className="font-medium">{user.name}</p>
<p className="text-sm text-muted-foreground">{user.email}</p>
</li>
))}
</ul>
);
}
カスタムフック
tsx
import { useState, useCallback } from 'react';
interface UseDisclosureReturn {
isOpen: boolean;
onOpen: () => void;
onClose: () => void;
onToggle: () => void;
}
export function useDisclosure(initialState = false): UseDisclosureReturn {
const [isOpen, setIsOpen] = useState(initialState);
const onOpen = useCallback(() => setIsOpen(true), []);
const onClose = useCallback(() => setIsOpen(false), []);
const onToggle = useCallback(() => setIsOpen((prev) => !prev), []);
return { isOpen, onOpen, onClose, onToggle };
}
パフォーマンス最適化
メモ化パターン
tsx
import { memo, useMemo, useCallback } from 'react';
// コンポーネントのメモ化
export const ExpensiveList = memo(function ExpensiveList({ items }: Props) {
// 計算結果のメモ化
const sortedItems = useMemo(
() => items.sort((a, b) => a.value - b.value),
[items]
);
// コールバックのメモ化
const handleClick = useCallback((id: string) => {
console.log('Clicked:', id);
}, []);
return (
<ul>
{sortedItems.map((item) => (
<li key={item.id} onClick={() => handleClick(item.id)}>
{item.name}
</li>
))}
</ul>
);
});
遅延ロード
tsx
import dynamic from 'next/dynamic';
import { Skeleton } from '@/components/ui/Skeleton';
// 重いコンポーネントを遅延ロード
const HeavyChart = dynamic(() => import('./HeavyChart'), {
loading: () => <Skeleton className="h-64 w-full" />,
ssr: false,
});
// ページレベルの遅延ロード
const AdminPage = dynamic(() => import('./pages/Admin'));
画像最適化
tsx
import Image from 'next/image';
export function Avatar({ src, alt }: Props) {
return (
<Image
src={src}
alt={alt}
width={40}
height={40}
className="rounded-full"
placeholder="blur"
blurDataURL="data:image/jpeg;base64,..."
/>
);
}
生成後の自動検証
- •
TypeScript型チェック
bashnpx tsc --noEmit
- •
ESLint
bashnpx eslint [生成ファイル] --fix
- •
アクセシビリティチェック
- •aria属性の存在確認
- •role属性の妥当性確認
ユーティリティ関数
cn(クラス名結合)
typescript
// lib/utils.ts
import { type ClassValue, clsx } from 'clsx';
import { twMerge } from 'tailwind-merge';
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}