react-frontend-expert について
React と TypeScript の開発者向けスキルです。コンポーネント、フック、ルーティング、データ取得、フォームの実装に加え、型とアクセシビリティの指針を示します。
主な機能
- 01関数コンポーネントの規約
- 02カスタムフックのパターン
- 03TanStack Query によるデータ取得
- 04TypeScript の規約
- 05フォーム検証とアクセシビリティ
活用シーン
- 01React コンポーネントの作成・変更
- 02ルーティングを含むページの構築
- 03クエリ、変更、キャッシュ無効化の実装
- 04検証付きフォームの開発
インストール
コマンドをコピーしてターミナルで実行します。
npx skills add https:/ / github.com/ hieutrtr/ ai1-skills --skill react-frontend-expertインストール後、Claude Code で/react-frontend-expertを入力して読み込まれたか確認します。
同梱ファイルreferences/component-templates.mdreferences/tanstack-query-patterns.md
詳細
React フロントエンドエキスパート
使用するタイミング
次の場合にこのスキルを有効にします:
- React コンポーネントの作成または変更(関数コンポーネントのみ)
- カスタムフック(
useXxx)の作成 - ルーティングを使用したページの構築
- TanStack Query を使用したデータ取得の実装
- バリデーションを含むフォームの処理
- React/TypeScript アプリケーションのプロジェクト構成の設定
次の場合はこのスキルを使用しないでください:
- コンポーネントまたはフックのテストの作成(
react-testing-patternsを使用) - E2E ブラウザーテスト(
e2e-testingを使用) - API コントラクトの設計(
api-design-patternsを使用) - バックエンドの実装(
python-backend-expertを使用) - デプロイまたは CI/CD(
deployment-pipelineを使用)
手順
プロジェクト構成
src/
├── api/ # API client functions and query options
│ ├── client.ts # Axios/fetch instance with interceptors
│ ├── users.ts # User API functions + query options
│ └── posts.ts
├── components/ # Shared, reusable UI components
│ ├── Button.tsx
│ ├── Modal.tsx
│ ├── Table/
│ │ ├── Table.tsx
│ │ └── TablePagination.tsx
│ └── Form/
│ ├── Input.tsx
│ └── Select.tsx
├── features/ # Domain-specific feature components
│ ├── users/
│ │ ├── UserList.tsx
│ │ └── UserProfile.tsx
│ └── posts/
│ └── PostEditor.tsx
├── hooks/ # Custom hooks
│ ├── useAuth.ts
│ ├── useDebounce.ts
│ └── usePagination.ts
├── layouts/ # Layout components
│ ├── MainLayout.tsx
│ └── AuthLayout.tsx
├── pages/ # Route-level page components
│ ├── HomePage.tsx
│ ├── LoginPage.tsx
│ └── users/
│ ├── UserListPage.tsx
│ └── UserDetailPage.tsx
├── types/ # Shared TypeScript types
│ ├── api.ts # API response types
│ └── user.ts
├── App.tsx # Root component with providers and router
└── main.tsx # Entry point
コンポーネント構成
関数コンポーネントのみを使用
interface UserCardProps {
user: User;
onEdit: (userId: number) => void;
showEmail?: boolean;
}
export function UserCard({ user, onEdit, showEmail = false }: UserCardProps) {
return (
<article className="user-card">
<h3>{user.displayName}</h3>
{showEmail && <p>{user.email}</p>}
<button type="button" onClick={() => onEdit(user.id)}>
Edit
</button>
</article>
);
}
コンポーネントのルール:
- 共有コンポーネントには名前付きエクスポートを使用します:
export function Button - ページコンポーネントにはデフォルトエクスポートを使用します:
export default function UserListPage - Props インターフェースの名前は
{Component}Propsにします - 関数のシグネチャで props を分割代入します
- コンポーネントは200行未満に保ちます。それを超える場合はサブコンポーネントまたはフックに切り出します
- 深い階層の prop の受け渡しよりも、
childrenとコンポジションを使用します React.FCは使用せず、通常の関数構文を使用します
コンポーネントファイルの構成
複雑なコンポーネントでは、関連ファイルを同じ場所にまとめます:
UserProfile/
├── UserProfile.tsx # Main component
├── UserProfile.css # Styles (or .module.css)
├── UserAvatar.tsx # Sub-component
└── index.ts # Re-export: export { UserProfile } from './UserProfile'
フックのルールとカスタムフック
フックのルール
- フックはトップレベルでのみ呼び出します。ループ、条件分岐、ネストされた関数内では決して呼び出しません
- フックは React の関数コンポーネントまたはカスタムフックからのみ呼び出します
- カスタムフックの名前は
useで始めます
カスタムフックのパターン
useDebounce:
export function useDebounce<T>(value: T, delayMs: number): T {
const [debouncedValue, setDebouncedValue] = useState(value);
useEffect(() => {
const timer = setTimeout(() => setDebouncedValue(value), delayMs);
return () => clearTimeout(timer);
}, [value, delayMs]);
return debouncedValue;
}
useAuth:
interface AuthContext {
user: User | null;
isAuthenticated: boolean;
login: (credentials: LoginCredentials) => Promise<void>;
logout: () => void;
}
const AuthContext = createContext<AuthContext | null>(null);
export function useAuth(): AuthContext {
const context = useContext(AuthContext);
if (!context) {
throw new Error("useAuth must be used within AuthProvider");
}
return context;
}
usePagination:
interface PaginationState {
cursor: string | null;
hasMore: boolean;
goToNext: (nextCursor: string) => void;
reset: () => void;
}
export function usePagination(): PaginationState {
const [cursor, setCursor] = useState<string | null>(null);
const [hasMore, setHasMore] = useState(true);
return {
cursor,
hasMore,
goToNext: (nextCursor: string) => {
setCursor(nextCursor);
},
reset: () => {
setCursor(null);
setHasMore(true);
},
};
}
カスタムフックに切り出すタイミング:
- ロジックが2つ以上のコンポーネントで再利用される
- コンポーネントの状態管理が複雑(
useStateの呼び出しが3回を超える) - 副作用をカプセル化する必要がある(サブスクリプション、タイマー)
- データ取得ロジックを共有できる
TanStack Query を使用したデータ取得
クエリーオプションファクトリー(推奨)
キーの衝突を防ぐため、クエリーキーと関数の定義を一元化します:
// api/users.ts
import { queryOptions } from "@tanstack/react-query";
export const userQueries = {
all: () =>
queryOptions({
queryKey: ["users"],
queryFn: () => apiClient.get<UserListResponse>("/users"),
}),
detail: (userId: number) =>
queryOptions({
queryKey: ["users", userId],
queryFn: () => apiClient.get<UserResponse>(`/users/${userId}`),
}),
search: (query: string) =>
queryOptions({
queryKey: ["users", "search", query],
queryFn: () => apiClient.get<UserListResponse>(`/users?q=${query}`),
enabled: query.length > 0,
}),
};
コンポーネントでのクエリーの使用
export function UserDetailPage({ userId }: { userId: number }) {
const { data: user, isPending, isError, error } = useQuery(
userQueries.detail(userId)
);
if (isPending) return <Spinner />;
if (isError) return <ErrorMessage error={error} />;
return <UserProfile user={user} />;
}
キャッシュ無効化を伴うミューテーション
export function useCreateUser() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (data: UserCreate) =>
apiClient.post<UserResponse>("/users", data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["users"] });
},
});
}
TanStack Query のルール:
staleTimeは0より大きい値に設定します(デフォルトの0は短すぎます):staleTime: 5 * 60 * 1000(5分)- ミューテーション後は
invalidateQueries()を使用します。手動のrefetch()は決して使用しません - すべての状態を処理します:
isPending、isError、data queryOptions()ファクトリーを使用します。キーのタイプミスや重複を防げます- パラメーターが不完全な状態でクエリーが実行されないよう、
enabledを使用します
TypeScript の規約
// Use `interface` for object shapes (components props, API responses)
interface User {
id: number;
email: string;
displayName: string;
role: "admin" | "editor" | "member";
}
// Use `type` for unions, intersections, and computed types
type UserRole = User["role"];
type CreateOrUpdate = UserCreate | UserUpdate;
// Discriminated unions for state machines
type AsyncState<T> =
| { status: "idle" }
| { status: "loading" }
| { status: "success"; data: T }
| { status: "error"; error: Error };
TypeScript のルール:
tsconfig.jsonでstrict: trueを有効にします。例外はありませんanyは決して使用しません。本当に型が不明な場合はunknownを使用します- リテラルオブジェクト型には
as constを使用します - 拡張可能な型には
interfaceを、それ以外にはtypeを優先します - 再利用可能なユーティリティ型やフックにはジェネリクスを使用します
- 共有する型は
types/ディレクトリからエクスポートします
フォームの処理
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
const userSchema = z.object({
email: z.string().email("Invalid email"),
displayName: z.string().min(1, "Required").max(100),
role: z.enum(["admin", "editor", "member"]),
});
type UserFormData = z.infer<typeof userSchema>;
export function UserForm({ onSubmit }: { onSubmit: (data: UserFormData) => void }) {
const {
register,
handleSubmit,
formState: { errors, isSubmitting },
} = useForm<UserFormData>({
resolver: zodResolver(userSchema),
});
return (
<form onSubmit={handleSubmit(onSubmit)} noValidate>
<label htmlFor="email">Email</label>
<input id="email" type="email" {...register("email")} aria-invalid={!!errors.email} />
{errors.email && <span role="alert">{errors.email.message}</span>}
<label htmlFor="displayName">Name</label>
<input id="displayName" {...register("displayName")} aria-invalid={!!errors.displayName} />
{errors.displayName && <span role="alert">{errors.displayName.message}</span>}
<button type="submit" disabled={isSubmitting}>Save</button>
</form>
);
}
アクセシビリティ要件
すべてのコンポーネントは WCAG 2.1 AA に準拠する必要があります:
- セマンティックな HTML を優先:
<div onClick>ではなく、<button>、<nav>、<main>、<article>を使用します - ラベル: すべてのフォーム入力に、
htmlForとidが一致する<label>を設定します - ARIA は必要な場合のみ使用: アイコンのみのボタンには
aria-label、動的な更新にはaria-live、エラーにはrole="alert"を使用します - キーボード操作: すべてのインタラクティブ要素に Tab キーで移動でき、Enter キーまたは Space キーで操作できるようにします
- フォーカス管理: ルート変更時にメインコンテンツへフォーカスを設定し、モーダル内ではフォーカスを閉じ込めます
- 色のコントラスト: 通常のテキストは最低4.5:1、大きなテキストは3:1にします
- 代替テキスト: すべての
<img>タグに説明的なaltを設定します(装飾画像の場合はalt="")
例
検索とページネーションを備えたユーザーリストページ
export default function UserListPage() {
const [search, setSearch] = useState("");
const debouncedSearch = useDebounce(search, 300);
const pagination = usePagination();
const { data, isPending } = useQuery(
userQueries.list({ q: debouncedSearch, cursor: pagination.cursor })
);
return (
<main>
<h1>Users</h1>
<input
type="search"
value={search}
onChange={(e) => { setSearch(e.target.value); pagination.reset(); }}
placeholder="Search users..."
aria-label="Search users"
/>
{isPending ? <Spinner /> : (
<>
<UserTable users={data.items} />
{data.hasMore && (
<button onClick={() => pagination.goToNext(data.nextCursor)}>
Load more
</button>
)}
</>
)}
</main>
);
}
エッジケース
-
フック内の古いクロージャー: 状態を参照するコールバックを使用する場合は、頻繁に変化する可変値には
useRefを使用するか、useCallback/useEffect の配列に依存関係を含めてください。 -
TanStack Query のキーの衝突: キーは階層的に構成してください。リストには
["users"]、詳細には["users", id]、フィルター付きリストには["users", { q, page }]を使用します。キーの定義を一元管理するには、queryOptions()ファクトリーを使用してください。 -
無限再レンダリング: よくある原因には、依存配列の欠落、レンダリング時に新しいオブジェクトや配列を作成すること(
useMemoでラップしてください)、適切な条件を設けずに useEffect 内で状態を更新することがあります。 -
ハイドレーションの不一致: 初回レンダリング時に、ブラウザー専用 API(window、localStorage)に依存するコンテンツをレンダリングしないでください。
useEffectを使用するか、typeof window !== "undefined"を確認してください。 -
メモリーリーク: useEffect のクリーンアップで非同期処理をキャンセルしてください。クエリでは TanStack Query が自動的に処理します。
注釈付きコンポーネントテンプレートについては references/component-templates.md を参照してください。
CRUD クエリのパターンについては references/tanstack-query-patterns.md を参照してください。