Communitygithub.com

junksidetm/darkside-toolkit

React、Next.js、状態管理、パフォーマンス最適化、UIベストプラクティスのためのフロントエンド開発パターン。ReactやNext.jsのコンポーネント、状態、レンダリングパフォーマンスの構築やレビュー時に使用します。

darkside-toolkit とは?

このスキルは、React、Next.js、およびパフォーマンスの高いユーザーインターフェースを構築するための、洗練されたフロントエンド開発パターンを提供します。コンポーネントパターン(コンポジション、複合コンポーネント、レンダープロップ)、カスタムフック(useToggle、useQuery、useDebounce)、状態管理(Context + Reducerパターン)、パフォーマンス最適化(メモ化、コード分割、仮想化)、フォームハンドリング、エラーバウンダリ、アニメーション、アクセシビリティなど、モダンなReact開発に必要な幅広いトピックをカバーしています。各パターンには、実用的で再利用可能なTypeScriptコード例が豊富に含まれており、開発者はベストプラクティスをすぐにプロジェクトに適用できます。このスキルは、Claude CodeやCursorなどのAIエージェントがReact/Next.jsのコードを生成、レビュー、リファクタリングする際に特に有用であり、一貫性のある高品質なフロントエンドコードベースの維持に貢献します。

対応~Claude Code~Codex CLI~Cursor
npx skills add https://github.com/junksidetm/darkside-toolkit/tree/HEAD/skills/frontend-patterns

お気に入りのAIに質問する

このエージェントスキルを事前に読み込んだ状態で新しいチャットを開きます。

ドキュメント

フロントエンド開発パターン

React、Next.js、およびパフォーマンスの高いユーザーインターフェースのためのモダンなフロントエンドパターン。

いつアクティブにするか

  • Reactコンポーネントの構築(コンポジション、props、レンダリング)
  • 状態管理(useState、useReducer、Zustand、Context)
  • データフェッチの実装(SWR、React Query、サーバーコンポーネント)
  • パフォーマンスの最適化(メモ化、仮想化、コード分割)
  • フォームの操作(バリデーション、制御入力、Zodスキーマ)
  • クライアントサイドのルーティングとナビゲーションの処理
  • アクセシブルでレスポンシブなUIパターンの構築

コンポーネントパターン

継承よりもコンポジション

// PASS: GOOD: コンポーネントのコンポジション
interface CardProps {
  children: React.ReactNode
  variant?: 'default' | 'outlined'
}

export function Card({ children, variant = 'default' }: CardProps) {
  return <div className={`card card-${variant}`}>{children}</div>
}

export function CardHeader({ children }: { children: React.ReactNode }) {
  return <div className="card-header">{children}</div>
}

export function CardBody({ children }: { children: React.ReactNode }) {
  return <div className="card-body">{children}</div>
}

// Usage
<Card>
  <CardHeader>Title</CardHeader>
  <CardBody>Content</CardBody>
</Card>

複合コンポーネント

interface TabsContextValue {
  activeTab: string
  setActiveTab: (tab: string) => void
}

const TabsContext = createContext<TabsContextValue | undefined>(undefined)

export function Tabs({ children, defaultTab }: {
  children: React.ReactNode
  defaultTab: string
}) {
  const [activeTab, setActiveTab] = useState(defaultTab)

  return (
    <TabsContext.Provider value={{ activeTab, setActiveTab }}>
      {children}
    </TabsContext.Provider>
  )
}

export function TabList({ children }: { children: React.ReactNode }) {
  return <div className="tab-list">{children}</div>
}

export function Tab({ id, children }: { id: string, children: React.ReactNode }) {
  const context = useContext(TabsContext)
  if (!context) throw new Error('Tab must be used within Tabs')

  return (
    <button
      className={context.activeTab === id ? 'active' : ''}
      onClick={() => context.setActiveTab(id)}
    >
      {children}
    </button>
  )
}

// Usage
<Tabs defaultTab="overview">
  <TabList>
    <Tab id="overview">Overview</Tab>
    <Tab id="details">Details</Tab>
  </TabList>
</Tabs>

レンダープロップパターン

interface DataLoaderProps<T> {
  url: string
  children: (data: T | null, loading: boolean, error: Error | null) => React.ReactNode
}

export function DataLoader<T>({ url, children }: DataLoaderProps<T>) {
  const [data, setData] = useState<T | null>(null)
  const [loading, setLoading] = useState(true)
  const [error, setError] = useState<Error | null>(null)

  useEffect(() => {
    fetch(url)
      .then(res => res.json())
      .then(setData)
      .catch(setError)
      .finally(() => setLoading(false))
  }, [url])

  return <>{children(data, loading, error)}</>
}

// Usage
<DataLoader<Market[]> url="/api/markets">
  {(markets, loading, error) => {
    if (loading) return <Spinner />
    if (error) return <Error error={error} />
    return <MarketList markets={markets!} />
  }}
</DataLoader>

カスタムフックパターン

状態管理フック

export function useToggle(initialValue = false): [boolean, () => void] {
  const [value, setValue] = useState(initialValue)

  const toggle = useCallback(() => {
    setValue(v => !v)
  }, [])

  return [value, toggle]
}

// Usage
const [isOpen, toggleOpen] = useToggle()

非同期データフェッチフック

interface UseQueryOptions<T> {
  onSuccess?: (data: T) => void
  onError?: (error: Error) => void
  enabled?: boolean
}

export function useQuery<T>(
  key: string,
  fetcher: () => Promise<T>,
  options?: UseQueryOptions<T>
) {
  const [data, setData] = useState<T | null>(null)
  const [error, setError] = useState<Error | null>(null)
  const [loading, setLoading] = useState(false)

  // 最新のfetcher/optionsをrefに保持することで、呼び出し側がインライン関数や
  // オブジェクトリテラルを渡しても、refetchの参照が安定します。
  // これがないと、レンダリングのたびに新しいrefetchが作成され、
  // 以下のeffectが状態更新のたびに再実行され、無限フェッチループが発生します。
  const fetcherRef = useRef(fetcher)
  const optionsRef = useRef(options)
  useEffect(() => {
    fetcherRef.current = fetcher
    optionsRef.current = options
  })

  const refetch = useCallback(async () => {
    setLoading(true)
    setError(null)

    try {
      const result = await fetcherRef.current()
      setData(result)
      optionsRef.current?.onSuccess?.(result)
    } catch (err) {
      const error = err as Error
      setError(error)
      optionsRef.current?.onError?.(error)
    } finally {
      setLoading(false)
    }
  }, [])

  const enabled = options?.enabled !== false

  useEffect(() => {
    if (enabled) {
      refetch()
    }
  }, [key, enabled, refetch])

  return { data, error, loading, refetch }
}

// Usage
const { data: markets, loading, error, refetch } = useQuery(
  'markets',
  () => fetch('/api/markets').then(r => r.json()),
  {
    onSuccess: data => console.log('Fetched', data.length, 'markets'),
    onError: err => console.error('Failed:', err)
  }
)

デバウンスフック

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

  useEffect(() => {
    const handler = setTimeout(() => {
      setDebouncedValue(value)
    }, delay)

    return () => clearTimeout(handler)
  }, [value, delay])

  return debouncedValue
}

// Usage
const [searchQuery, setSearchQuery] = useState('')
const debouncedQuery = useDebounce(searchQuery, 500)

useEffect(() => {
  if (debouncedQuery) {
    performSearch(debouncedQuery)
  }
}, [debouncedQuery])

状態管理パターン

Context + Reducer パターン

interface State {
  markets: Market[]
  selectedMarket: Market | null
  loading: boolean
}

type Action =
  | { type: 'SET_MARKETS'; payload: Market[] }
  | { type: 'SELECT_MARKET'; payload: Market }
  | { type: 'SET_LOADING'; payload: boolean }

function reducer(state: State, action: Action): State {
  switch (action.type) {
    case 'SET_MARKETS':
      return { ...state, markets: action.payload }
    case 'SELECT_MARKET':
      return { ...state, selectedMarket: action.payload }
    case 'SET_LOADING':
      return { ...state, loading: action.payload }
    default:
      return state
  }
}

const MarketContext = createContext<{
  state: State
  dispatch: Dispatch<Action>
} | undefined>(undefined)

export function MarketProvider({ children }: { children: React.ReactNode }) {
  const [state, dispatch] = useReducer(reducer, {
    markets: [],
    selectedMarket: null,
    loading: false
  })

  return (
    <MarketContext.Provider value={{ state, dispatch }}>
      {children}
    </MarketContext.Provider>
  )
}

export function useMarkets() {
  const context = useContext(MarketContext)
  if (!context) throw new Error('useMarkets must be used within MarketProvider')
  return context
}

パフォーマンス最適化

メモ化

// PASS: 高コストな計算にはuseMemo
const sortedMarkets = useMemo(() => {
  // ソート前にコピー - Array.prototype.sortは破壊的変更を行う
  return [...markets].sort((a, b) => b.volume - a.volume)
}, [markets])

// PASS: 子コンポーネントに渡す関数にはuseCallback
const handleSearch = useCallback((query: string) => {
  setSearchQuery(query)
}, [])

// PASS: 純粋コンポーネントにはReact.memo
export const MarketCard = React.memo<MarketCardProps>(({ market }) => {
  return (
    <div className="market-card">
      <h3>{market.name}</h3>
      <p>{market.description}</p>
    </div>
  )
})

コード分割と遅延読み込み

import { lazy, Suspense } from 'react'

// PASS: 重いコンポーネントは遅延読み込み
const HeavyChart = lazy(() => import('./HeavyChart'))
const ThreeJsBackground = lazy(() => import('./ThreeJsBackground'))

export function Dashboard() {
  return (
    <div>
      <Suspense fallback={<ChartSkeleton />}>
        <HeavyChart data={data} />
      </Suspense>

      <Suspense fallback={null}>
        <ThreeJsBackground />
      </Suspense>
    </div>
  )
}

長いリストの仮想化

import { useVirtualizer } from '@tanstack/react-virtual'

export function VirtualMarketList({ markets }: { markets: Market[] }) {
  const parentRef = useRef<HTMLDivElement>(null)

  const virtualizer = useVirtualizer({
    count: markets.length,
    getScrollElement: () => parentRef.current,
    estimateSize: () => 100,  // Estimated row height
    overscan: 5  // Extra items to render
  })

  return (
    <div ref={parentRef} style={{ height: '600px', overflow: 'auto' }}>
      <div
        style={{
          height: `${virtualizer.getTotalSize()}px`,
          position: 'relative'
        }}
      >
        {virtualizer.getVirtualItems().map(virtualRow => (
          <div
            key={virtualRow.index}
            style={{
              position: 'absolute',
              top: 0,
              left: 0,
              width: '100%',
              height: `${virtualRow.size}px`,
              transform: `translateY(${virtualRow.start}px)`
            }}
          >
            <MarketCard market={markets[virtualRow.index]} />
          </div>
        ))}
      </div>
    </div>
  )
}

フォームハンドリングパターン

バリデーション付き制御フォーム

interface FormData {
  name: string
  description: string
  endDate: string
}

interface FormErrors {
  name?: string
  description?: string
  endDate?: string
}

export function CreateMarketForm() {
  const [formData, setFormData] = useState<FormData>({
    name: '',
    description: '',
    endDate: ''
  })

  const [errors, setErrors] = useState<FormErrors>({})

  const validate = (): boolean => {
    const newErrors: FormErrors = {}

    if (!formData.name.trim()) {
      newErrors.name = 'Name is required'
    } else if (formData.name.length > 200) {
      newErrors.name = 'Name must be under 200 characters'
    }

    if (!formData.description.trim()) {
      newErrors.description = 'Description is required'
    }

    if (!formData.endDate) {
      newErrors.endDate = 'End date is required'
    }

    setErrors(newErrors)
    return Object.keys(newErrors).length === 0
  }

  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault()

    if (!validate()) return

    try {
      await createMarket(formData)
      // Success handling
    } catch (error) {
      // Error handling
    }
  }

  return (
    <form onSubmit={handleSubmit}>
      <input
        value={formData.name}
        onChange={e => setFormData(prev => ({ ...prev, name: e.target.value }))}
        placeholder="Market name"
      />
      {errors.name && <span className="error">{errors.name}</span>}

      {/* Other fields */}

      <button type="submit">Create Market</button>
    </form>
  )
}

エラーバウンダリパターン

interface ErrorBoundaryState {
  hasError: boolean
  error: Error | null
}

export class ErrorBoundary extends React.Component<
  { children: React.ReactNode },
  ErrorBoundaryState
> {
  state: ErrorBoundaryState = {
    hasError: false,
    error: null
  }

  static getDerivedStateFromError(error: Error): ErrorBoundaryState {
    return { hasError: true, error }
  }

  componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
    console.error('Error boundary caught:', error, errorInfo)
  }

  render() {
    if (this.state.hasError) {
      return (
        <div className="error-fallback">
          <h2>Something went wrong</h2>
          <p>{this.state.error?.message}</p>
          <button onClick={() => this.setState({ hasError: false })}>
            Try again
          </button>
        </div>
      )
    }

    return this.props.children
  }
}

// Usage
<ErrorBoundary>
  <App />
</ErrorBoundary>

アニメーションパターン

Framer Motionアニメーション

import { motion, AnimatePresence } from 'framer-motion'

// PASS: リストアニメーション
export function AnimatedMarketList({ markets }: { markets: Market[] }) {
  return (
    <AnimatePresence>
      {markets.map(market => (
        <motion.div
          key={market.id}
          initial={{ opacity: 0, y: 20 }}
          animate={{ opacity: 1, y: 0 }}
          exit={{ opacity: 0, y: -20 }}
          transition={{ duration: 0.3 }}
        >
          <MarketCard market={market} />
        </motion.div>
      ))}
    </AnimatePresence>
  )
}

// PASS: モーダルアニメーション
export function Modal({ isOpen, onClose, children }: ModalProps) {
  return (
    <AnimatePresence>
      {isOpen && (
        <>
          <motion.div
            className="modal-overlay"
            initial={{ opacity: 0 }}
            animate={{ opacity: 1 }}
            exit={{ opacity: 0 }}
            onClick={onClose}
          />
          <motion.div
            className="modal-content"
            initial={{ opacity: 0, scale: 0.9, y: 20 }}
            animate={{ opacity: 1, scale: 1, y: 0 }}
            exit={{ opacity: 0, scale: 0.9, y: 20 }}
          >
            {children}
          </motion.div>
        </>
      )}
    </AnimatePresence>
  )
}

アクセシビリティパターン

キーボードナビゲーション

export function Dropdown({ options, onSelect }: DropdownProps) {
  const [isOpen, setIsOpen] = useState(false)
  const [activeIndex, setActiveIndex] = useState(0)

  const handleKeyDown = (e: React.KeyboardEvent) => {
    switch (e.key) {
      case 'ArrowDown':
        e.preventDefault()
        setActiveIndex(i => Math.min(i + 1, options.length - 1))
        break
      case 'ArrowUp':
        e.preventDefault()
        setActiveIndex(i => Math.max(i - 1, 0))
        break
      case 'Enter':
        e.preventDefault()
        onSelect(options[activeIndex])
        if (isOpen) setIsOpen(false)
        break
      case 'Escape':
        setIsOpen(false)
        break
    }
  }

  return (
    <div className="dropdown" onKeyDown={handleKeyDown}>
      <button onClick={() => setIsOpen(!isOpen)} aria-haspopup="listbox" aria-expanded={isOpen}>
        {options[activeIndex]?.label}
      </button>
      {isOpen && (
        <ul role="listbox" className="dropdown-menu">
          {options.map((option, index) => (
            <li
              key={option.value}
              role="option"
              aria-selected={index === activeIndex}
              className={index === activeIndex ? 'active' : ''}
              onClick={() => {
                onSelect(option)
                setIsOpen(false)
              }}
            >
              {option.label}
            </li>
          ))}
        </ul>
      )}
    </div>
  )
}

ARIAラベルとランドマーク

// PASS: 意味のあるaria-labelを提供
export function SearchBar() {
  return (
    <div role="search">
      <input
        type="search"
        aria-label="Search markets"
        placeholder="Search..."
      />
    </div>
  )
}

// PASS: 動的コンテンツのライブリージョン
export function LiveRegion({ message }: { message: string }) {
  return (
    <div aria-live="polite" aria-atomic="true" className="sr-only">
      {message}
    </div>
  )
}

// PASS: フォームエラーとフィールドの関連付け
export function FormField({ label, error, children }: FormFieldProps) {
  const id = useId()
  const errorId = `${id}-error`

  return (
    <div className="form-field">
      <label htmlFor={id}>{label}</label>
      {React.cloneElement(children, { id, 'aria-describedby': error ? errorId : undefined })}
      {error && (
        <span id={errorId} role="alert" className="error">
          {error}
        </span>
      )}
    </div>
  )
}

// Usage
<FormField label="Name" error={errors.name}>
  <input value={formData.name} onChange={...} />
</FormField>

レスポンシブデザインパターン

// PASS: カスタムフックでブレークポイントを管理
export function useMediaQuery(query: string): boolean {
  const [matches, setMatches] = useState(() => window.matchMedia(query).matches)

  useEffect(() => {
    const mediaQuery = window.matchMedia(query)
    const handler = (event: MediaQueryListEvent) => setMatches(event.matches)

    mediaQuery.addEventListener('change', handler)
    return () => mediaQuery.removeEventListener('change', handler)
  }, [query])

  return matches
}

// Usage
const isMobile = useMediaQuery('(max-width: 768px)')
const isTablet = useMediaQuery('(min-width: 769px) and (max-width: 1024px)')
const isDesktop = useMediaQuery('(min-width: 1025px)')

テストパターン

React Testing Library

import { render, screen, fireEvent, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'

// PASS: ユーザーインタラクションのテスト
describe('MarketCard', () => {
  it('calls onSelect when clicked', async () => {
    const onSelect = vi.fn()
    const market = { id: '1', name: 'Test Market', description: 'A test' }

    render(<MarketCard market={market} onSelect={onSelect} />)

    const user = userEvent.setup()
    await user.click(screen.getByText('Test Market'))

    expect(onSelect).toHaveBeenCalledWith(market)
  })

  it('displays market description', () => {
    const market = { id: '1', name: 'Test Market', description: 'A test' }

    render(<MarketCard market={market} />)

    expect(screen.getByText('A test')).toBeInTheDocument()
  })
})

// PASS: 非同期アクションのテスト
describe('CreateMarketForm', () => {
  it('shows error on empty submission', async () => {
    render(<CreateMarketForm />)

    const user = userEvent.setup()
    await user.click(screen.getByText('Create Market'))

    expect(screen.getByText('Name is required')).toBeInTheDocument()
  })

  it('calls createMarket with valid data', async () => {
    const createMarket = vi.fn()
    render(<CreateMarketForm onSubmit={createMarket} />)

    const user = userEvent.setup()
    await user.type(screen.getByPlaceholderText('Market name'), 'New Market')
    await user.type(screen.getByPlaceholderText('Description'), 'Description')
    await user.type(screen.getByPlaceholderText('End date'), '2024-12-31')
    await user.click(screen.getByText('Create Market'))

    await waitFor(() => {
      expect(createMarket).toHaveBeenCalledWith({
        name: 'New Market',
        description: 'Description',
        endDate: '2024-12-31'
      })
    })
  })
})

ベストプラクティスのまとめ

やってはいけないこと

  • ❌ 深いpropsのバケツリレー(prop drilling)
  • ❌ 不必要な再レンダリング
  • ❌ コンポーネント内での直接的なDOM操作
  • ❌ クリーンアップのないグローバルイベントリスナー
  • ❌ useEffectを使ったデータフェッチ(専用ライブラリを使うこと)
  • ❌ 不適切なキー(配列のインデックスなど)

やるべきこと

  • ✅ コンポーネントのコンポジションを優先する
  • ✅ 再利用可能なカスタムフックを作成する
  • ✅ 高コストな計算をメモ化する
  • ✅ 大きなリストを仮想化する
  • ✅ アクセシブルなHTMLとARIA属性を使用する
  • ✅ コンポーネントとフックをテストする
  • ✅ TypeScriptで型安全を保つ
  • ✅ パフォーマンス監視にReact DevToolsとProfilerを使用する

Individual skills in this repo

This repo contains 3 individual skills — each has its own dedicated page.

関連スキル