mcpField
F
frontend

frontend

github.com/miles990開発26194 回インストール40 週間前に更新
リポジトリ

frontend について

frontendは、モダンなWebアプリ開発のパターンを扱うスキルで、ReactとNext.jsの例を中心に解説します。スタイリングやパフォーマンス改善、よくある問題、フロントエンドコードの検証ルールも含みます。

主な機能

  1. 01Reactコンポーネントとフック
  2. 02Next.js App Routerのパターン
  3. 03Tailwind CSSとCSS Modules
  4. 04パフォーマンス最適化手法
  5. 05よくある問題とコードチェック

活用シーン

  1. 01Reactインターフェースの構築
  2. 02Next.jsのルートとアクションの実装
  3. 03コンポーネントの状態管理方式の選択
  4. 04フロントエンドコードのリスク確認

インストール

コマンドをコピーしてターミナルで実行します。

$npx skills add https://github.com/miles990/claude-software-skills --skill frontend

インストール後、Claude Code で/frontendを入力して読み込まれたか確認します。

同梱ファイル

SKILL.md と一緒にインストールされる参考資料やスクリプト。

templates/
README.md
templates/react/
eslint.config.js
tsconfig.json
templates/vite/
vite.config.ts

詳細

フロントエンド開発

概要

高性能な Web アプリケーションを構築するための、モダンなフロントエンド開発パターン、フレームワーク、ベストプラクティスです。


React エコシステム

コンポーネントパターン
// Functional component with hooks
import { useState, useEffect, useCallback, useMemo } from 'react';

interface UserListProps {
  initialFilter?: string;
  onSelect: (user: User) => void;
}

function UserList({ initialFilter = '', onSelect }: UserListProps) {
  const [users, setUsers] = useState<User[]>([]);
  const [filter, setFilter] = useState(initialFilter);
  const [loading, setLoading] = useState(true);

  // Memoized filtered list
  const filteredUsers = useMemo(
    () => users.filter(u => u.name.toLowerCase().includes(filter.toLowerCase())),
    [users, filter]
  );

  // Stable callback reference
  const handleSelect = useCallback((user: User) => {
    onSelect(user);
  }, [onSelect]);

  useEffect(() => {
    async function fetchUsers() {
      setLoading(true);
      const data = await api.getUsers();
      setUsers(data);
      setLoading(false);
    }
    fetchUsers();
  }, []);

  if (loading) return <Skeleton count={5} />;

  return (
    <div>
      <SearchInput value={filter} onChange={setFilter} />
      {filteredUsers.map(user => (
        <UserCard
          key={user.id}
          user={user}
          onClick={() => handleSelect(user)}
        />
      ))}
    </div>
  );
}
カスタムフック
// Data fetching hook
function useFetch<T>(url: string) {
  const [data, setData] = useState<T | null>(null);
  const [error, setError] = useState<Error | null>(null);
  const [loading, setLoading] = useState(true);

  useEffect(() => {
    const controller = new AbortController();

    async function fetchData() {
      try {
        setLoading(true);
        const response = await fetch(url, { signal: controller.signal });
        if (!response.ok) throw new Error(`HTTP ${response.status}`);
        const json = await response.json();
        setData(json);
        setError(null);
      } catch (err) {
        if (err instanceof Error && err.name !== 'AbortError') {
          setError(err);
        }
      } finally {
        setLoading(false);
      }
    }

    fetchData();
    return () => controller.abort();
  }, [url]);

  return { data, error, loading };
}

// Local storage hook
function useLocalStorage<T>(key: string, initialValue: T) {
  const [storedValue, setStoredValue] = useState<T>(() => {
    try {
      const item = window.localStorage.getItem(key);
      return item ? JSON.parse(item) : initialValue;
    } catch {
      return initialValue;
    }
  });

  const setValue = useCallback((value: T | ((val: T) => T)) => {
    const valueToStore = value instanceof Function ? value(storedValue) : value;
    setStoredValue(valueToStore);
    window.localStorage.setItem(key, JSON.stringify(valueToStore));
  }, [key, storedValue]);

  return [storedValue, setValue] as const;
}

// Debounce hook
function useDebounce<T>(value: T, delay: number): T {
  const [debouncedValue, setDebouncedValue] = useState(value);

  useEffect(() => {
    const timer = setTimeout(() => setDebouncedValue(value), delay);
    return () => clearTimeout(timer);
  }, [value, delay]);

  return debouncedValue;
}
状態管理
// Zustand - simple global state
import { create } from 'zustand';
import { persist } from 'zustand/middleware';

interface CartStore {
  items: CartItem[];
  addItem: (item: CartItem) => void;
  removeItem: (id: string) => void;
  clearCart: () => void;
  total: () => number;
}

const useCartStore = create<CartStore>()(
  persist(
    (set, get) => ({
      items: [],
      addItem: (item) => set((state) => ({
        items: [...state.items, item]
      })),
      removeItem: (id) => set((state) => ({
        items: state.items.filter(i => i.id !== id)
      })),
      clearCart: () => set({ items: [] }),
      total: () => get().items.reduce((sum, item) => sum + item.price, 0)
    }),
    { name: 'cart-storage' }
  )
);

// React Query / TanStack Query
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';

function useUsers() {
  return useQuery({
    queryKey: ['users'],
    queryFn: () => api.getUsers(),
    staleTime: 5 * 60 * 1000, // 5 minutes
  });
}

function useCreateUser() {
  const queryClient = useQueryClient();

  return useMutation({
    mutationFn: (newUser: CreateUserInput) => api.createUser(newUser),
    onSuccess: () => {
      queryClient.invalidateQueries({ queryKey: ['users'] });
    },
  });
}

Next.js / App Router

サーバーコンポーネント
// app/users/page.tsx - Server Component (default)
async function UsersPage() {
  // Direct database access in server component
  const users = await db.user.findMany();

  return (
    <div>
      <h1>Users</h1>
      <UserList users={users} />
    </div>
  );
}

// Client component for interactivity
'use client';

import { useState } from 'react';

function UserList({ users }: { users: User[] }) {
  const [selected, setSelected] = useState<string | null>(null);

  return (
    <ul>
      {users.map(user => (
        <li
          key={user.id}
          onClick={() => setSelected(user.id)}
          className={selected === user.id ? 'selected' : ''}
        >
          {user.name}
        </li>
      ))}
    </ul>
  );
}
サーバーアクション
// app/actions.ts
'use server';

import { revalidatePath } from 'next/cache';
import { redirect } from 'next/navigation';

export async function createUser(formData: FormData) {
  const name = formData.get('name') as string;
  const email = formData.get('email') as string;

  await db.user.create({
    data: { name, email }
  });

  revalidatePath('/users');
  redirect('/users');
}

// Usage in component
function CreateUserForm() {
  return (
    <form action={createUser}>
      <input name="name" required />
      <input name="email" type="email" required />
      <button type="submit">Create</button>
    </form>
  );
}
Route Handlers
// app/api/users/route.ts
import { NextRequest, NextResponse } from 'next/server';

export async function GET(request: NextRequest) {
  const searchParams = request.nextUrl.searchParams;
  const limit = parseInt(searchParams.get('limit') || '10');

  const users = await db.user.findMany({ take: limit });
  return NextResponse.json(users);
}

export async function POST(request: NextRequest) {
  const body = await request.json();

  const user = await db.user.create({ data: body });
  return NextResponse.json(user, { status: 201 });
}

CSS とスタイリング

Tailwind CSS
// Component with Tailwind
function Card({ title, children }: { title: string; children: React.ReactNode }) {
  return (
    <div className="rounded-lg border border-gray-200 bg-white p-6 shadow-sm
                    hover:shadow-md transition-shadow duration-200
                    dark:bg-gray-800 dark:border-gray-700">
      <h2 className="text-xl font-semibold text-gray-900 dark:text-white mb-4">
        {title}
      </h2>
      <div className="text-gray-600 dark:text-gray-300">
        {children}
      </div>
    </div>
  );
}

// With clsx/cn for conditional classes
import { clsx } from 'clsx';
import { twMerge } from 'tailwind-merge';

function cn(...inputs: ClassValue[]) {
  return twMerge(clsx(inputs));
}

function Button({
  variant = 'primary',
  size = 'md',
  className,
  ...props
}: ButtonProps) {
  return (
    <button
      className={cn(
        'inline-flex items-center justify-center rounded-md font-medium',
        'focus:outline-none focus:ring-2 focus:ring-offset-2',
        {
          'bg-blue-600 text-white hover:bg-blue-700': variant === 'primary',
          'bg-gray-200 text-gray-900 hover:bg-gray-300': variant === 'secondary',
          'px-3 py-1.5 text-sm': size === 'sm',
          'px-4 py-2 text-base': size === 'md',
          'px-6 py-3 text-lg': size === 'lg',
        },
        className
      )}
      {...props}
    />
  );
}
CSS Modules
// Button.module.css
.button {
  padding: 0.5rem 1rem;
  border-radius: 0.375rem;
  font-weight: 500;
}

.primary {
  background-color: var(--color-primary);
  color: white;
}

.secondary {
  background-color: var(--color-gray-200);
  color: var(--color-gray-900);
}

// Button.tsx
import styles from './Button.module.css';

function Button({ variant = 'primary', children }) {
  return (
    <button className={`${styles.button} ${styles[variant]}`}>
      {children}
    </button>
  );
}

パフォーマンス最適化

コード分割
// Dynamic imports
import dynamic from 'next/dynamic';

const HeavyChart = dynamic(() => import('./HeavyChart'), {
  loading: () => <ChartSkeleton />,
  ssr: false, // Client-only component
});

// React.lazy with Suspense
const Dashboard = lazy(() => import('./Dashboard'));

function App() {
  return (
    <Suspense fallback={<Loading />}>
      <Dashboard />
    </Suspense>
  );
}
仮想化
import { useVirtualizer } from '@tanstack/react-virtual';

function VirtualList({ items }: { items: Item[] }) {
  const parentRef = useRef<HTMLDivElement>(null);

  const virtualizer = useVirtualizer({
    count: items.length,
    getScrollElement: () => parentRef.current,
    estimateSize: () => 50,
  });

  return (
    <div ref={parentRef} className="h-[400px] overflow-auto">
      <div
        style={{
          height: `${virtualizer.getTotalSize()}px`,
          position: 'relative',
        }}
      >
        {virtualizer.getVirtualItems().map((virtualItem) => (
          <div
            key={virtualItem.key}
            style={{
              position: 'absolute',
              top: 0,
              transform: `translateY(${virtualItem.start}px)`,
              height: `${virtualItem.size}px`,
            }}
          >
            {items[virtualItem.index].name}
          </div>
        ))}
      </div>
    </div>
  );
}
画像最適化
import Image from 'next/image';

function ProductImage({ src, alt }: { src: string; alt: string }) {
  return (
    <Image
      src={src}
      alt={alt}
      width={400}
      height={300}
      placeholder="blur"
      blurDataURL="data:image/jpeg;base64,/9j/4AAQ..."
      sizes="(max-width: 768px) 100vw, 400px"
      priority={false}
    />
  );
}

テスト

コンポーネントテスト
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';

describe('LoginForm', () => {
  it('submits with valid credentials', async () => {
    const onSubmit = vi.fn();
    render(<LoginForm onSubmit={onSubmit} />);

    await userEvent.type(screen.getByLabelText(/email/i), '[email protected]');
    await userEvent.type(screen.getByLabelText(/password/i), 'password123');
    await userEvent.click(screen.getByRole('button', { name: /sign in/i }));

    await waitFor(() => {
      expect(onSubmit).toHaveBeenCalledWith({
        email: '[email protected]',
        password: 'password123',
      });
    });
  });

  it('shows validation errors', async () => {
    render(<LoginForm onSubmit={vi.fn()} />);

    await userEvent.click(screen.getByRole('button', { name: /sign in/i }));

    expect(await screen.findByText(/email is required/i)).toBeInTheDocument();
  });
});

関連スキル

  • [[testing-strategies]] - フロントエンドテスト
  • [[performance-optimization]] - Web パフォーマンス
  • [[ux-principles]] - ユーザーエクスペリエンス

Sharp Edges(よくある落とし穴)

これらはフロントエンド開発で最もよくある、代償の大きいミスです

SE-1: useEffect の無限ループ
  • 深刻度: critical
  • 状況: useEffect 内で state を更新し、その state が dependency でもあるため、無限に再レンダリングされる
  • 原因: React の dependency の仕組みを理解していない、object/array を dependency にしている
  • 症状:
    • ページがフリーズする、または極端に遅くなる
    • ブラウザのメモリが増え続ける
    • Network タブに大量の重複リクエストが表示される
  • 検出: useEffect.*setState.*\].*\{|useEffect\(\(\).*fetch.*\[\]
  • 解決策: dependencies を正しく設定する、useCallback/useMemo を使う、値の追跡には useRef の使用を検討する
SE-2: メモリリーク (Memory Leak)
  • 深刻度: high
  • 状況: Component の unmount 後も subscription、timer、または async 操作が state を更新している
  • 原因: effect をクリーンアップしていない、進行中の fetch をキャンセルしていない
  • 症状:
    • コンソールに「Can't perform state update on unmounted component」の警告が出る
    • アプリケーションを使うほど遅くなる
    • ページを切り替えた後に古いデータが一瞬表示される
  • 検出: useEffect.*setInterval(?!.*return)|useEffect.*addEventListener(?!.*return.*remove)|useEffect.*subscribe(?!.*return)
  • 解決策: useEffect 内で cleanup 関数を return する、AbortController を使って fetch をキャンセルする
SE-3: Props Drilling 地獄
  • 深刻度: medium
  • 状況: 深い階層の component にデータを渡すために、中間層が不要な props を渡す必要がある
  • 原因: Context や状態管理を使っていない、component の構造設計が不適切
  • 症状:
    • 1 つの prop を変更するのに 5 個以上の component を修正する必要がある
    • 中間層の component に「渡すだけ」の props がたくさんある
    • データの流れを追いにくい
  • 検出: props\.\w+.*props\.\w+.*props\.\w+|{.*,.*,.*,.*,.*,.*}.*=>
  • 解決策: Context、Zustand/Redux、または Component Composition を使う
SE-4: 早すぎる最適化 (Premature Optimization)
  • 深刻度: medium
  • 状況: パフォーマンス問題がないのに useMemo/useCallback/React.memo を過度に使う
  • 原因: これらの hooks の用途を誤解している、やみくもに「最適化」している
  • 症状:
    • コードが useMemo だらけなのにパフォーマンスが改善しない
    • かえって余分なメモリと比較処理のせいで遅くなる
    • コードが読みにくい
  • 検出: useMemo\(\(\).*return.*\d+|useCallback\(\(\).*console|React\.memo\(.*\)(?!.*areEqual)
  • 解決策: まずパフォーマンスを測定する、問題があると確定したときだけ最適化する、これらのツールをいつ使うべきか理解する
SE-5: 安全でない HTML レンダリング
  • 深刻度: critical
  • 状況: dangerouslySetInnerHTML を使う、またはユーザー入力の HTML をそのままレンダリングする
  • 原因: rich text をレンダリングするため、XSS リスクを理解していない
  • 症状:
    • XSS 攻撃の脆弱性
    • ユーザーが悪意のあるスクリプトを注入できる
    • サイトがフィッシングやデータ窃取に悪用される
  • 検出: dangerouslySetInnerHTML|innerHTML.*=.*user|v-html.*user
  • 解決策: DOMPurify でサニタイズする、安全な Markdown レンダラーを使う、ユーザー入力をそのままレンダリングしない

検証

V-1: useEffect に cleanup がない
  • タイプ: regex
  • 深刻度: high
  • パターン: useEffect\s*\([^)]*=>\s*\{[^}]*(setInterval|addEventListener|subscribe)[^}]*\}(?![^}]*return)
  • メッセージ: useEffect with subscription/timer missing cleanup function
  • 修正提案: Add cleanup: return () => clearInterval(id) or return () => unsubscribe()
  • 適用: *.tsx, *.jsx
V-2: dangerouslySetInnerHTML の禁止
  • タイプ: regex
  • 深刻度: critical
  • パターン: dangerouslySetInnerHTML
  • メッセージ: dangerouslySetInnerHTML is a security risk (XSS)
  • 修正提案: Use DOMPurify: dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(html) }}
  • 適用: *.tsx, *.jsx
V-3: useEffect の空の dependency array + state 更新
  • タイプ: regex
  • 深刻度: high
  • パターン: useEffect\s*\([^)]*=>\s*\{[^}]*set\w+\([^}]*\},\s*\[\s*\]\s*\)
  • メッセージ: useEffect with empty deps but updates state - possible stale closure
  • 修正提案: Add necessary dependencies or use useRef for values that shouldn't trigger re-run
  • 適用: *.tsx, *.jsx
V-4: インラインスタイルのオブジェクトリテラルを禁止
  • 種類: regex
  • 重大度: medium
  • パターン: style=\{\s*\{[^}]+\}\s*\}
  • メッセージ: Inline style object creates new reference on every render
  • 修正提案: 変数に抽出するか useMemo を使用してください: const styles = useMemo(() => ({...}), [])
  • 適用: *.tsx, *.jsx
V-5: JSX で index を key として使用することを禁止
  • 種類: regex
  • 重大度: medium
  • パターン: key=\{(index|i|idx)\}
  • メッセージ: Using array index as key can cause issues with reordering
  • 修正提案: 一意の識別子を使用してください: key={item.id} または key={item.uniqueField}
  • 適用: *.tsx, *.jsx

コメント