React + TypeScriptコードの実装品質基準(TSDoc・厳密な型定義・コンポーネント分割50行・カスタムフック抽出・Tailwind/clsx)を自動適用するスキル。既存プロジェクト内でのコード作成・編集・リファクタリングが対象。 使用するケース: 「Reactで実装して」「コンポーネント作って」「TSXを書いて」「フック作って」「TypeScriptで型つけて」「リファクタリングして」など、コンポーネント・フック・ユーティリティの作成/改修依頼。 使わないケース: プロジェクト新規作成(react-project-initを使う)、E2Eテスト作成(playwright-testを使う)、Vue/Svelte等の他フレームワーク、CSS-in-JSの大規模導入。
React + TypeScriptのコードを書くとき、このスキルの基準に従って実装する。新規作成でもリファクタリングでも同じ基準を適用する。
すべての公開コンポーネント・フック・ユーティリティ関数にTSDocを書く。
/**
* 商品カードコンポーネント。
*
* 商品画像・名前・価格を表示し、カートへの追加操作を提供する。
* 在庫切れの場合はボタンを無効化し、視覚的に区別する。
*
* @param props - コンポーネントのプロパティ
* @param props.product - 表示する商品データ
* @param props.onAddToCart - カート追加時のコールバック。商品IDを引数に呼ばれる
*
* @example
* ```tsx
* <ProductCard
* product={{ id: "1", name: "Book", price: 1500, inStock: true }}
* onAddToCart={(id) => console.log(`Added: ${id}`)}
* />
* ```
*/
判断基準: 「このコンポーネント/フックを初めて使う人が、TSDocだけで正しく使えるか?」を基準にする。Props名から明らかなことは省略してよいが、コールバックのタイミング・副作用・エッジケースは必ず書く。
コードの「なぜ(Why)」を説明するコメントを書く。「何をしているか(What)」はコード自体が語るべき。
// 良い例:なぜそうするのかを説明
// リスト項目が1000件を超えるケースがあり、再レンダリングで
// 目に見えるカクつきが出るため仮想化で描画を制限する
const virtualizedItems = useVirtualizer({ count: items.length, ... });
// 悪い例:コードの直訳
// アイテムを仮想化する
const virtualizedItems = useVirtualizer({ count: items.length, ... });
書くべきコメント:
// TODO(user): #1234 期限2024-06)厳密な型定義でコンポーネントの契約を明確にする。
// Propsはinterfaceで定義する(拡張性のため)
interface UserProfileProps {
/** ユーザーデータ */
user: User;
/** 編集モードの有効/無効 */
isEditable?: boolean;
/** プロフィール更新時のコールバック */
onUpdate?: (updated: User) => void;
}
// データ型はtypeで定義する(合成しやすい)
type User = {
id: string;
name: string;
email: string;
role: "admin" | "member" | "guest";
};
// 判別共用体型で非同期状態を表現する(不正な状態を型レベルで排除)
// `isLoading: boolean` + `data: T | null` + `error: string | null` ではなく、
// statusフィールドで判別する形にする。これにより「loading中なのにdataがある」
// といった矛盾した状態をTypeScriptコンパイラが検出してくれる。
type AsyncState<T> =
| { status: "idle"; data: undefined; error: undefined }
| { status: "loading"; data: undefined; error: undefined }
| { status: "success"; data: T; error: undefined }
| { status: "error"; data: undefined; error: Error };
ルール:
interface、データ型はtypeを基本にする(interfaceはextends可能で宣言マージが効く)anyは使わない。不明な型はunknownで受けてナローイングするstatusフィールドで状態を判別し、各状態で有効なプロパティを型レベルで制約するReact.MouseEvent<HTMLButtonElement>のように要素型を明示するReact.FCも使わない)。TypeScriptのJSX推論に任せる外部 → 内部 → 相対パス、の順に並べる。typeインポートは import type で値インポートと分離する。
// 外部(React/サードパーティ)
import { useState, useCallback } from "react";
import { useQuery } from "@tanstack/react-query";
import clsx from "clsx";
// 内部(エイリアス `@/`)
import { Button } from "@/components/ui/Button";
import { useAuth } from "@/hooks/useAuth";
import type { User } from "@/types/user";
// 相対パス
import { ProfileAvatar } from "./ProfileAvatar";
グループ間は1行空行。並び順はESLint (eslint-plugin-import または simple-import-sort) に任せる。
コードを書いたら、以下のコマンドで品質をチェックする:
# lint チェック(自動修正つき)
npx eslint . --fix
# フォーマット
npx prettier --write "src/**/*.{ts,tsx,css,json}"
プロジェクトに設定がなければ、react-project-initスキルで定義されている設定(eslint.config.js / .prettierrc)を追加する。
1つのコンポーネントは1つの役割を持つ。コンポーネント関数の本体(functionの{から}まで)が50行を超えたら分割を検討する。
ここで言う「本体」はロジック + JSXの関数本体のみを指す。import文・Props型定義・TSDocは含めない。
分割の判断基準:
FormFieldコンポーネントに切り出しページ(オーケストレータ)コンポーネントはフック呼び出しと子コンポーネントの配置が主な責務になるため、本体がやや長くなりやすい。その場合でも以下を徹底する:
useCallbackやuseStateを3つ以上書いているなら、専用フック(usePageName)への抽出を検討する// 悪い例:1コンポーネントに複数の関心事
function OrderPage() {
// データ取得(20行)...
// フォームバリデーション(30行)...
// カート計算ロジック(15行)...
// 巨大なJSX(100行)...
}
// 良い例:関心事ごとに分割し、ページはフック+JSX配置のみ
function OrderPage() {
const { order, isLoading } = useOrder(orderId);
if (isLoading) return <OrderSkeleton />;
return (
<div className="space-y-6">
<OrderSummary order={order} />
<ShippingForm orderId={order.id} />
<PaymentSection amount={order.total} />
</div>
);
}
フォームの繰り返しパターン: label + input + errorが同じ構造で複数回出現する場合はFormFieldを抽出する。
// FormFieldで繰り返しを解消する
interface FormFieldProps {
label: string;
value: string;
error?: string;
onChange: (value: string) => void;
type?: "text" | "textarea";
}
function FormField({ label, value, error, onChange, type = "text" }: FormFieldProps) {
const Component = type === "textarea" ? "textarea" : "input";
return (
<div>
<label className="mb-1 block text-sm font-medium text-gray-700">{label}</label>
<Component
value={value}
onChange={(e) => onChange(e.target.value)}
className={clsx("w-full rounded-lg border px-3 py-2", error && "border-red-500")}
/>
{error && <p className="mt-1 text-sm text-red-500">{error}</p>}
</div>
);
}
1ファイル1コンポーネントが原則。ファイルが300行を超えたら分割を検討する。
機能(Feature)ベースのディレクトリ構成を推奨する:
src/
├── components/ # 汎用UIコンポーネント
│ └── ui/
│ ├── Button.tsx
│ ├── Input.tsx
│ └── Modal.tsx
├── features/ # 機能単位のモジュール
│ ├── auth/
│ │ ├── components/
│ │ │ ├── LoginForm.tsx
│ │ │ └── SignupForm.tsx
│ │ ├── hooks/
│ │ │ └── useAuth.ts
│ │ ├── stores/
│ │ │ └── authStore.ts
│ │ └── types.ts
│ └── products/
│ ├── components/
│ │ ├── ProductCard.tsx
│ │ └── ProductList.tsx
│ ├── hooks/
│ │ └── useProducts.ts
│ └── types.ts
├── hooks/ # アプリ全体で使う汎用フック
│ ├── useLocalStorage.ts
│ └── useMediaQuery.ts
├── stores/ # グローバルストア
│ └── uiStore.ts
├── types/ # 共通型定義
│ └── api.ts
├── lib/ # ユーティリティ関数
│ ├── api-client.ts
│ └── format.ts
├── App.tsx
├── main.tsx
└── index.css
小規模(コンポーネント10個以下)の場合はfeatures/を省略してフラットにしてよい。
childrenやSlotパターンで柔軟にレイアウトを組むvalue + onChange)を基本とする// 良い例:合成パターンでレイアウトを柔軟にする
function Card({ children }: { children: React.ReactNode }) {
return <div className="rounded-lg border bg-white p-4 shadow-sm">{children}</div>;
}
function CardHeader({ children }: { children: React.ReactNode }) {
return <div className="mb-3 border-b pb-2 font-bold">{children}</div>;
}
function CardBody({ children }: { children: React.ReactNode }) {
return <div>{children}</div>;
}
// 使用側で自由に組み合わせられる
<Card>
<CardHeader>注文履歴</CardHeader>
<CardBody><OrderList orders={orders} /></CardBody>
</Card>
3回以上出現するロジックパターン、または1つのコンポーネント内で複雑化したロジックはカスタムフックとして抽出する。
抽出すべきもの:
useFetchUser、useProducts)useLoginForm、useSearchFilter)useLocalStorage、useMediaQuery)useDebounce、useInterval)// hooks/useLocalStorage.ts
import { useState, useCallback } from "react";
/**
* localStorageと同期するstate管理フック。
*
* 値の読み書きはJSON.parse/stringifyで自動変換される。
* localStorageにアクセスできない環境ではinitialValueのみで動作する。
*
* @param key - localStorageのキー
* @param initialValue - 初期値
* @returns [現在の値, セッター関数] のタプル
*/
function useLocalStorage<T>(key: string, initialValue: T): [T, (value: T | ((prev: T) => T)) => void] {
const [storedValue, setStoredValue] = useState<T>(() => {
try {
const item = localStorage.getItem(key);
return item ? (JSON.parse(item) as T) : initialValue;
} catch {
return initialValue;
}
});
const setValue = useCallback(
(value: T | ((prev: T) => T)) => {
setStoredValue((prev) => {
const nextValue = value instanceof Function ? value(prev) : value;
localStorage.setItem(key, JSON.stringify(nextValue));
return nextValue;
});
},
[key],
);
return [storedValue, setValue];
}
export { useLocalStorage };
過度な最適化は避けるが、以下のケースでは積極的に適用する:
useMemo: 計算コストが高い派生データ(フィルタ・ソート・集計)useCallback: 子コンポーネントにPropsとして渡すコールバック関数React.memo: リストのアイテムコンポーネントなど、親の再レンダリングで不要に再描画されるものReact.lazy + Suspense: ルート単位のコード分割// リストアイテムはmemo化する(リストが長いとき効果大)
const ProductCard = React.memo(function ProductCard({ product, onSelect }: ProductCardProps) {
return (
<div onClick={() => onSelect(product.id)}>
<h3>{product.name}</h3>
<p>{product.price.toLocaleString()}円</p>
</div>
);
});
やらないこと:
React.memoで囲む(計測なしの最適化は複雑さだけ増やす)useMemo / useCallback(効果がない)テストはユーザーの操作と振る舞いを検証する。実装の詳細(state・内部メソッド)はテストしない。
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, it, expect, vi } from "vitest";
import { Counter } from "./Counter";
describe("Counter", () => {
it("incrementボタンでカウントが1増える", async () => {
const user = userEvent.setup();
render(<Counter initialCount={0} />);
await user.click(screen.getByRole("button", { name: "増やす" }));
expect(screen.getByText("1")).toBeInTheDocument();
});
it("上限に達するとincrementボタンが無効になる", () => {
render(<Counter initialCount={10} max={10} />);
expect(screen.getByRole("button", { name: "増やす" })).toBeDisabled();
});
});
テストの方針:
getByRole > getByLabelText > getByText の優先順でクエリする(アクセシビリティ順)@testing-library/user-eventを使う(fireEventより実際の操作に近い)findByクエリかwaitForで待つTailwind CSSでスタイリングする。条件付きクラスはclsx(またはcnユーティリティ)で合成する。
import clsx from "clsx";
interface ButtonProps {
variant?: "primary" | "secondary" | "danger";
size?: "sm" | "md" | "lg";
disabled?: boolean;
children: React.ReactNode;
onClick?: () => void;
}
function Button({ variant = "primary", size = "md", disabled, children, onClick }: ButtonProps) {
return (
<button
className={clsx(
"rounded font-medium transition-colors focus:outline-none focus:ring-2",
{
"bg-blue-600 text-white hover:bg-blue-700": variant === "primary",
"bg-gray-200 text-gray-800 hover:bg-gray-300": variant === "secondary",
"bg-red-600 text-white hover:bg-red-700": variant === "danger",
},
{
"px-2 py-1 text-sm": size === "sm",
"px-4 py-2 text-base": size === "md",
"px-6 py-3 text-lg": size === "lg",
},
disabled && "cursor-not-allowed opacity-50",
)}
disabled={disabled}
onClick={onClick}
>
{children}
</button>
);
}
ルール:
style属性)は使わない。Tailwindのクラスで表現するtailwind.config.ts)で定義するsm: md: lg:のブレークポイントを使う既存コードのリファクタリングを依頼された場合も、上記すべての基準を適用する。加えて:
リファクタリングの優先順位:
any排除、Props型定義)コードを書き終えたら、以下を確認する:
anyを使っていないかnpx eslint .がエラーなしで通るかnpx prettier --check "src/**/*.{ts,tsx}"が差分なしで通るかuseMemo / useCallbackが必要な箇所にのみ使われているか