# フロントエンド専門知識 ## フロントエンドの層構造 依存方向は一方向。逆方向の依存は禁止。 ``` app/routes/ → features/ → shared/ ``` | 層 | 責務 | ルール | |---|------|--------| | `app/routes/` | ルート定義のみ | UIロジックを持たない。feature の View を呼ぶだけ | | `features/` | 機能単位の自己完結モジュール | 他の feature を直接参照しない | | `shared/` | 全 feature 横断の共有コード | feature に依存しない | ルートファイルは薄いラッパーに徹する。 ```tsx // CORRECT - ルートは薄い // app/routes/schedule-management.tsx export default function ScheduleManagementRoute() { return } // WRONG - ルートにロジックを書く export default function ScheduleManagementRoute() { const [filter, setFilter] = useState('all') const { data } = useListSchedules({ filter }) return } ``` View コンポーネント(`features/*/components/*-view.tsx`)がデータ取得・状態管理を担当する。 ``` ルート(route) → View(データ取得・状態管理) → 子コンポーネント(表示) ``` ## コンポーネント設計 1ファイルにベタ書きしない。必ずコンポーネント分割する。 分離が必須なケース: - 独自のstateを持つ → 必ず分離 - 50行超のJSX → 分離 - 再利用可能 → 分離 - 責務が複数 → 分離 - ページ内の独立したセクション → 分離 | 基準 | 判定 | |------|------| | 1コンポーネント200行超 | 分割を検討 | | 1コンポーネント300行超 | REJECT | | 表示とロジックが混在 | 分離を検討 | | Props drilling(3階層以上) | 状態管理の導入を検討 | | 複数の責務を持つコンポーネント | REJECT | 良いコンポーネント: - 単一責務: 1つのことをうまくやる - 自己完結: 必要な依存が明確 - テスト可能: 副作用が分離されている コンポーネント分類: | 種類 | 責務 | 例 | |------|------|-----| | Container | データ取得・状態管理 | `UserListContainer` | | Presentational | 表示のみ | `UserCard` | | Layout | 配置・構造 | `PageLayout`, `Grid` | | Utility | 共通機能 | `ErrorBoundary`, `Portal` | ### UIプリミティブの設計原則 shared/components/ui/ に配置するHTML要素ラッパーの設計ルール: - `forwardRef` で ref を転送する(外部からの制御を可能にする) - `className` を受け取り、外からスタイル拡張可能にする - ネイティブ props をスプレッドで透過する(`...props`) - variants は別ファイルに分離する(`button.variants.ts`) ```tsx // CORRECT - プリミティブの設計 export const Button = forwardRef( ({ variant, size, className, children, ...props }, ref) => { return ( ) } ) // WRONG - refもclassNameも透過しない閉じたコンポーネント export const Button = ({ label, onClick }: { label: string; onClick: () => void }) => { return } ``` ディレクトリ構成: ``` features/{feature-name}/ ├── components/ │ ├── {feature}-view.tsx # メインビュー(子を組み合わせる) │ ├── {sub-component}.tsx # サブコンポーネント │ └── index.ts ├── hooks/ ├── types.ts └── index.ts ``` ## 状態管理 子コンポーネントは自身で状態を変更しない。イベントを親にバブリングし、親が状態を操作する。 ```tsx // 子が自分で状態を変更(NG) const ChildBad = ({ initialValue }: { initialValue: string }) => { const [value, setValue] = useState(initialValue) return setValue(e.target.value)} /> } // 親が状態を管理、子はコールバックで通知(OK) const ChildGood = ({ value, onChange }: { value: string; onChange: (v: string) => void }) => { return onChange(e.target.value)} /> } const Parent = () => { const [value, setValue] = useState('') return } ``` 例外(子がローカルstate持ってOK): - UI専用の一時状態(ホバー、フォーカス、アニメーション) - 親に伝える必要がない完全にローカルな状態 | 基準 | 判定 | |------|------| | 不要なグローバル状態 | ローカル化を検討 | | 同じ状態が複数箇所で管理 | 正規化が必要 | | 子から親への状態変更(逆方向データフロー) | REJECT | | APIレスポンスをそのまま状態に | 正規化を検討 | | useEffectの依存配列が不適切 | REJECT | 状態配置の判断基準: | 状態の性質 | 推奨配置 | |-----------|---------| | UIの一時的な状態(モーダル開閉等) | ローカル(useState) | | フォームの入力値 | ローカル or フォームライブラリ | | 複数コンポーネントで共有 | Context or 状態管理ライブラリ | | サーバーデータのキャッシュ | TanStack Query等のデータフェッチライブラリ | ## データ取得 API呼び出しはルート(View)コンポーネントで行い、子コンポーネントにはpropsで渡す。 ```tsx // CORRECT - ルートでデータ取得、子に渡す const OrderDetailView = () => { const { data: order, isLoading, error } = useGetOrder(orderId) const { data: items } = useListOrderItems(orderId) if (isLoading) return if (error) return return ( ) } // WRONG - 子コンポーネントが自分でデータ取得 const OrderSummary = ({ orderId }) => { const { data: order } = useGetOrder(orderId) // ... } ``` UIの状態変更でパラメータが変わる場合(週切り替え、フィルタ等): 状態もViewレベルで管理し、コンポーネントにはコールバックを渡す。 ```tsx // CORRECT - 状態もViewで管理 const ScheduleView = () => { const [currentWeek, setCurrentWeek] = useState(startOfWeek(new Date())) const { data } = useListSchedules({ from: format(currentWeek, 'yyyy-MM-dd'), to: format(endOfWeek(currentWeek), 'yyyy-MM-dd'), }) return ( ) } // WRONG - コンポーネント内で状態管理+データ取得 const WeeklyCalendar = ({ facilityId }) => { const [currentWeek, setCurrentWeek] = useState(...) const { data } = useListSchedules({ facilityId, from, to }) // ... } ``` 例外(コンポーネント内フェッチが許容されるケース): | ケース | 理由 | |--------|------| | 独立ウィジェット | どのページにも置ける自己完結型コンポーネント | | 無限スクロール | スクロール位置というUI内部状態に依存 | | 検索オートコンプリート | 入力値に依存したリアルタイム検索 | | リアルタイム更新 | WebSocket/Pollingでの自動更新 | | モーダル内の詳細取得 | 開いたときだけ追加データを取得 | ### 独立ウィジェットパターン WordPress のサイドバーウィジェットのように、どのページにも「置くだけ」で動くコンポーネント。親のデータフローに参加しない自己完結型。 該当する例: - 通知バッジ・通知ベル(未読数を自分で取得) - ログインユーザー情報表示(ヘッダーのアバター等) - お知らせバナー - 天気・為替など外部データ表示 - アクティビティフィード(サイドバー) ```tsx // OK - 独立ウィジェット。どのページに置いても自分で動く const NotificationBell = () => { const { data } = useNotificationCount({ refetchInterval: 30000 }) return ( ) } // OK - ヘッダーに常駐するユーザーメニュー const UserMenu = () => { const { data: user } = useCurrentUser() return } ``` ウィジェットと判定する条件(すべて満たすこと): - 親のデータと**完全に無関係**(親から props でデータを受け取る必要がない) - 親の状態に**影響を与えない**(結果を親にバブリングしない) - **どのページに置いても同じ動作**をする(ページ固有のコンテキストに依存しない) 1つでも満たさない場合は View でデータ取得し、props で渡す。 ```tsx // WRONG - ウィジェットに見えるが、orderId という親のコンテキストに依存 const OrderStatusWidget = ({ orderId }: { orderId: string }) => { const { data } = useGetOrder(orderId) return } // CORRECT - 親のデータフローに参加するならpropsで受け取る const OrderStatusWidget = ({ status }: { status: OrderStatus }) => { return } ``` 判断基準: 「親が管理する意味がない / 親に影響を与えない」ケースのみ許容。 | 基準 | 判定 | |------|------| | コンポーネント内で直接fetch | Container層に分離 | | エラーハンドリングなし | REJECT | | ローディング状態の未処理 | REJECT | | キャンセル処理なし | 警告 | | N+1クエリ的なフェッチ | REJECT | ## 共有コンポーネントと抽象化 ### カテゴリ分類 shared コンポーネントは責務別にサブディレクトリで分類する。 ``` shared/components/ ├── ui/ # HTMLプリミティブのラッパー(Button, Card, Badge, Dialog) ├── form/ # フォーム入力要素(TextInput, Select, Checkbox) ├── layout/ # ページ構造・ルート保護(Layout, ProtectedRoute) ├── navigation/ # ナビゲーション(Tabs, BackLink, SidebarItem) ├── data-display/ # データ表示(Table, DetailField, Calendar) ├── feedback/ # 状態フィードバック(LoadingState, ErrorState) ├── domain/ # ドメイン固有だが横断的(StatusBadge, CategoryBadge) └── index.ts # barrel export ``` | カテゴリ | 配置基準 | |---------|---------| | ui/ | HTML要素を薄くラップ。ドメイン知識を持たない | | form/ | ラベル・エラー・必須マークを統合したフォーム部品 | | layout/ | ページ全体の骨格。認証・ロール制御を含む | | domain/ | 特定ドメインに依存するが、複数 feature で共有 | ui/ と domain/ の判断基準: ドメイン用語がコンポーネント名やpropsに含まれるなら domain/。 ### 共有化の基準 同じパターンのUIは共有コンポーネント化する。インラインスタイルのコピペは禁止。 ```tsx // WRONG - インラインスタイルのコピペ // CORRECT - 共有コンポーネント使用 ``` 共有コンポーネント化すべきパターン: - アイコンボタン(閉じる、編集、削除等) - ローディング/エラー表示 - ステータスバッジ - タブ切り替え - ラベル+値の表示(詳細画面) - 検索入力 - カラー凡例 過度な汎用化を避ける: ```tsx // WRONG - IconButtonに無理やりステッパー用バリアントを追加 export const iconButtonVariants = cva('...', { variants: { variant: { default: '...', outlined: '...', // ステッパー専用、他で使わない }, size: { medium: 'p-2', stepper: 'w-8 h-8', // outlinedとセットでしか使わない }, }, }) // CORRECT - 用途別に専用コンポーネント export function StepperButton(props) { return ( ) } ``` 別コンポーネントにすべきサイン: - 「このvariantはこのsizeとセット」のような暗黙の制約がある - 追加したvariantが元のコンポーネントの用途と明らかに違う - 使う側のprops指定が複雑になる ### テーマ差分とデザイントークン 同じ機能コンポーネントを再利用しつつ見た目だけ変える場合は、デザイントークン + テーマスコープで管理する。 原則: - 色・余白・角丸・影・タイポをトークン(CSS Variables)として定義する - 画面/ロール別の差分はテーマスコープ(例: `.consumer-theme`, `.admin-theme`)で上書きする - コンポーネント内に16進カラー値(`#xxxxxx`)を直書きしない - ロジック差分(API・状態管理)と見た目差分(トークン)を混在させない ```css /* tokens.css */ :root { --color-bg-page: #f3f4f6; --color-surface: #ffffff; --color-text-primary: #1f2937; --color-border: #d1d5db; --color-accent: #2563eb; } .consumer-theme { --color-bg-page: #f7f8fa; --color-accent: #4daca1; } ``` ```tsx // same component, different look by scope
``` 運用ルール: - 共通UI(Button/Card/Input/Tabs)はトークン参照のみで実装する - feature側はテーマ共通クラス(例: `surface`, `title`, `chip`)を利用し、装飾ロジックを重複させない - 追加テーマ実装時は「トークン追加 → スコープ上書き → 既存コンポーネント流用」の順で進める レビュー観点: - 直書き色・直書き余白のコピペがないか - 同一UIパターンがテーマごとに別コンポーネント化されていないか - 見た目変更のためにデータ取得/状態管理が改変されていないか NG例: - 見た目差分のために `ButtonConsumer`, `ButtonAdmin` を乱立 - featureコンポーネントごとに色を直書き - テーマ切り替えのたびにAPIレスポンス整形ロジックを変更 ## 抽象化レベルの評価 ### 条件分岐の肥大化検出 | パターン | 判定 | |---------|------| | 同じ条件分岐が3箇所以上 | 共通コンポーネントに抽出 → REJECT | | propsによる分岐が5種類以上 | コンポーネント分割を検討 | | render内の三項演算子のネスト | 早期リターンまたはコンポーネント分離 → REJECT | | 型による分岐レンダリング | ポリモーフィックコンポーネントを検討 | ### 抽象度の不一致検出 | パターン | 問題 | 修正案 | |---------|------|--------| | データ取得ロジックがJSXに混在 | 読みにくい | カスタムフックに抽出 | | ビジネスロジックがコンポーネントに混在 | 責務違反 | hooks/utilsに分離 | | スタイル計算ロジックが散在 | 保守困難 | ユーティリティ関数に抽出 | | 同じ変換処理が複数箇所に | DRY違反 | 共通関数に抽出 | 良い抽象化の例: ```tsx // 条件分岐が肥大化 function UserBadge({ user }) { if (user.role === 'admin') { return 管理者 } else if (user.role === 'moderator') { return モデレーター } else if (user.role === 'premium') { return プレミアム } else { return 一般 } } // Mapで抽象化 const ROLE_CONFIG = { admin: { label: '管理者', className: 'bg-red-500' }, moderator: { label: 'モデレーター', className: 'bg-yellow-500' }, premium: { label: 'プレミアム', className: 'bg-purple-500' }, default: { label: '一般', className: 'bg-gray-500' }, } function UserBadge({ user }) { const config = ROLE_CONFIG[user.role] ?? ROLE_CONFIG.default return {config.label} } ``` ```tsx // 抽象度が混在 function OrderList() { const [orders, setOrders] = useState([]) useEffect(() => { fetch('/api/orders') .then(res => res.json()) .then(data => setOrders(data)) }, []) return orders.map(order => (
{order.total.toLocaleString()}円
)) } // 抽象度を揃える function OrderList() { const { data: orders } = useOrders() // データ取得を隠蔽 return orders.map(order => ( )) } ``` ## フロントエンドとバックエンドの責務分離 ### 表示形式の責務 バックエンドは「データ」を返し、フロントエンドが「表示形式」に変換する。 ```tsx // フロントエンド: 表示形式に変換 export function formatPrice(amount: number): string { return `¥${amount.toLocaleString()}` } export function formatDate(date: Date): string { return format(date, 'yyyy年M月d日') } ``` | 基準 | 判定 | |------|------| | バックエンドが表示用文字列を返している | 設計見直しを提案 | | 同じフォーマット処理が複数箇所にコピペ | ユーティリティ関数に統一 | | コンポーネント内でインラインフォーマット | 関数に抽出 | ### ドメインロジックの配置(SmartUI排除) ドメインロジック(ビジネスルール)はバックエンドに配置。フロントエンドは状態の表示・編集のみ。 ドメインロジックとは: - 集約のビジネスルール(在庫判定、価格計算、ステータス遷移) - バリデーション(業務制約の検証) - 不変条件の保証 フロントエンドの責務: - サーバーから受け取った状態を表示 - ユーザー入力を収集し、コマンドとしてバックエンドに送信 - UI専用の一時状態管理(フォーカス、ホバー、モーダル開閉) - 表示形式の変換(フォーマット、ソート、フィルタ) | 基準 | 判定 | |------|------| | フロントエンドで価格計算・在庫判定 | バックエンドに移動 → REJECT | | フロントエンドでステータス遷移ルール | バックエンドに移動 → REJECT | | フロントエンドでビジネスバリデーション | バックエンドに移動 → REJECT | | サーバー側で計算可能な値をフロントで再計算 | 冗長 → REJECT | 良い例 vs 悪い例: ```tsx // BAD - フロントエンドでビジネスルール function OrderForm({ order }: { order: Order }) { const totalPrice = order.items.reduce((sum, item) => sum + item.price * item.quantity, 0 ) const canCheckout = totalPrice >= 1000 && order.items.every(i => i.stock > 0) return } // GOOD - バックエンドから受け取った状態を表示 function OrderForm({ order }: { order: Order }) { // totalPrice, canCheckout はサーバーから受け取る return ( <>
{formatPrice(order.totalPrice)}
) } ``` ```tsx // BAD - フロントエンドでステータス遷移判定 function TaskCard({ task }: { task: Task }) { const canStart = task.status === 'pending' && task.assignee !== null const canComplete = task.status === 'in_progress' && /* 複雑な条件... */ return ( <> ) } // GOOD - サーバーが許可するアクションを返す function TaskCard({ task }: { task: Task }) { // task.allowedActions = ['start', 'cancel'] など、サーバーが計算 const canStart = task.allowedActions.includes('start') const canComplete = task.allowedActions.includes('complete') return ( <> ) } ``` 例外(フロントエンドにロジックを置いてもOK): | ケース | 理由 | |--------|------| | UI専用バリデーション | 「必須入力」「文字数制限」等のUXフィードバック(サーバー側でも検証必須) | | クライアント側フィルタ/ソート | サーバーから受け取ったリストの表示順序変更 | | 表示条件の分岐 | 「ログイン済みなら詳細表示」等のUI制御 | | リアルタイムフィードバック | 入力中のプレビュー表示 | 判断基準: 「この計算結果がサーバーとズレたら業務が壊れるか?」 - YES → バックエンドに配置(ドメインロジック) - NO → フロントエンドでもOK(表示ロジック) ## 横断的関心事の処理層 横断的関心事は適切な層で処理する。コンポーネント内に散在させない。 | 関心事 | 処理層 | パターン | |-------|--------|---------| | 認証トークン付与 | APIクライアント層 | リクエストインターセプタ | | 認証エラー(401/403) | APIクライアント層 | レスポンスインターセプタ | | ルート保護 | レイアウト層 | ProtectedRoute + Outlet | | ロール別振り分け | レイアウト層 | ユーザー種別による分岐 | | ローディング/エラー表示 | View(Container)層 | 早期リターン | ```tsx // CORRECT - 横断的関心事はインターセプタ層で処理 // api/axios-instance.ts instance.interceptors.request.use((config) => { const token = localStorage.getItem('auth_token') if (token) { config.headers.Authorization = `Bearer ${token}` } return config }) // WRONG - 各コンポーネントで個別にトークンを付与 const MyComponent = () => { const token = localStorage.getItem('auth_token') const { data } = useQuery({ queryFn: () => fetch('/api/data', { headers: { Authorization: `Bearer ${token}` }, }), }) } ``` ```tsx // CORRECT - ルート保護はレイアウト層で // shared/components/layout/protected-route.tsx function ProtectedRoute() { const { isAuthenticated } = useAuthStore() if (!isAuthenticated) return return } // routes でラップ }> } /> // WRONG - 各ページで個別に認証チェック function DashboardView() { const { isAuthenticated } = useAuthStore() if (!isAuthenticated) return return
...
} ``` ## パフォーマンス | 基準 | 判定 | |------|------| | 不要な再レンダリング | 最適化が必要 | | 大きなリストの仮想化なし | 警告 | | 画像の最適化なし | 警告 | | バンドルに未使用コード | tree-shakingを確認 | | メモ化の過剰使用 | 本当に必要か確認 | 最適化チェックリスト: - `React.memo` / `useMemo` / `useCallback` は適切か - 大きなリストは仮想スクロール対応か - Code Splittingは適切か - 画像はlazy loadingされているか アンチパターン: ```tsx // レンダリングごとに新しいオブジェクト // 定数化 or useMemo const style = useMemo(() => ({ color: 'red' }), []); ``` ## アクセシビリティ | 基準 | 判定 | |------|------| | インタラクティブ要素にキーボード対応なし | REJECT | | 画像にalt属性なし | REJECT | | フォーム要素にlabelなし | REJECT | | 色だけで情報を伝達 | REJECT | | フォーカス管理の欠如(モーダル等) | REJECT | チェックリスト: - セマンティックHTMLを使用しているか - ARIA属性は適切か(過剰でないか) - キーボードナビゲーション可能か - スクリーンリーダーで意味が通じるか - カラーコントラストは十分か ## TypeScript/型安全性 | 基準 | 判定 | |------|------| | `any` 型の使用 | REJECT | | 型アサーション(as)の乱用 | 要検討 | | Props型定義なし | REJECT | | イベントハンドラの型が不適切 | 修正が必要 | ## フロントエンドセキュリティ | 基準 | 判定 | |------|------| | dangerouslySetInnerHTML使用 | XSSリスクを確認 | | ユーザー入力の未サニタイズ | REJECT | | 機密情報のフロントエンド保存 | REJECT | | CSRFトークンの未使用 | 要確認 | ## テスタビリティ | 基準 | 判定 | |------|------| | data-testid等の未付与 | 警告 | | テスト困難な構造 | 分離を検討 | | ビジネスロジックのUIへの埋め込み | REJECT | ## アンチパターン検出 以下を見つけたら REJECT: | アンチパターン | 問題 | |---------------|------| | God Component | 1コンポーネントに全機能が集中 | | Prop Drilling | 深いPropsバケツリレー | | Inline Styles乱用 | 保守性低下 | | useEffect地獄 | 依存関係が複雑すぎる | | Premature Optimization | 不要なメモ化 | | Magic Strings | ハードコードされた文字列 | | Hidden Dependencies | 子コンポーネントの隠れたAPI呼び出し | | Over-generalization | 無理やり汎用化したコンポーネント |