2025年4月30日

React 設計指針 (1‑6) — 解説 & ソースコード例

前提: Next.js (App Router) + TypeScript で開発するプロジェクトを想定しています。

各セクションにコードスニペットを付けて、実際にどのように実装へ落とし込むかイメージできるようにしました。全コードは 最小実装 を意識し、読みやすさを優先しています。


1. UI を小さな再利用可能コンポーネントに分割する

解説

  • 画面を視覚ブロック単位に分割し、それぞれを関数コンポーネント化 (Thinking in React)。
  • 1 ファイル 1 コンポーネントを基本とし、export / import で組み合わせ。
  • 差し替え・ユニットテスト・ストーリーブック化が容易になります。

ソースコード例

// components/Button.tsx
import { ReactNode } from 'react'

export type ButtonProps = {
  children: ReactNode
  onClick?: () => void
  variant?: 'primary' | 'secondary'
}

export const Button = ({
  children,
  onClick,
  variant = 'primary',
}: ButtonProps) => (
  <button
    onClick={onClick}
    className={
      variant === 'primary'
        ? 'rounded bg-blue-600 px-4 py-2 text-white hover:bg-blue-700'
        : 'rounded bg-gray-200 px-4 py-2 text-gray-900 hover:bg-gray-300'
    }
  >
    {children}
  </button>
)
// components/TodoItem.tsx
import { Button } from './Button'

type Todo = { id: string; title: string; done: boolean }

type TodoItemProps = {
  todo: Todo
  onToggle: (id: string) => void
}

export const TodoItem = ({ todo, onToggle }: TodoItemProps) => (
  <li className="flex items-center gap-2">
    <input
      type="checkbox"
      checked={todo.done}
      onChange={() => onToggle(todo.id)}
    />
    <span className={todo.done ? 'text-gray-400 line-through' : ''}>
      {todo.title}
    </span>
  </li>
)

2. 単一責任を守る

解説

  • 1 コンポーネント = 1 役割 を徹底し、UI/ビジネスロジック/データ取得を分離。
  • UI が複雑化したら、ロジックをカスタムフックに切り出して再利用します。

ソースコード例

// hooks/useFilteredTodos.ts
import { useMemo } from 'react'

import { Todo } from '../types'

export const useFilteredTodos = (todos: Todo[], showDone: boolean) =>
  useMemo(
    () => todos.filter((t) => (showDone ? true : !t.done)),
    [todos, showDone]
  )
// components/TodoList.client.tsx  (Client Component)
'use client'

import { useState } from 'react'

import { useFilteredTodos } from '../hooks/useFilteredTodos'
import { Todo } from '../types'
import { Button } from './Button'
import { TodoItem } from './TodoItem'

// components/TodoList.client.tsx  (Client Component)

// components/TodoList.client.tsx  (Client Component)

type Props = { initialTodos: Todo[] }

export default function TodoList({ initialTodos }: Props) {
  const [todos, setTodos] = useState(initialTodos)
  const [showDone, setShowDone] = useState(false)
  const filtered = useFilteredTodos(todos, showDone)

  const toggle = (id: string) =>
    setTodos((prev) =>
      prev.map((t) => (t.id === id ? { ...t, done: !t.done } : t))
    )

  return (
    <section>
      <Button variant="secondary" onClick={() => setShowDone((s) => !s)}>
        {showDone ? 'すべて表示' : '未完了のみ'}
      </Button>
      <ul className="mt-4 space-y-2">
        {filtered.map((todo) => (
          <TodoItem key={todo.id} todo={todo} onToggle={toggle} />
        ))}
      </ul>
    </section>
  )
}

3. 状態 (state) は必要最小範囲に置く

解説

  • 子コンポーネント間で共有する場合は 最も近い共通親 にリフトアップ。
  • propsバケツリレーが深くなる場合のみ Contextstate 管理ライブラリ (Zustand / Jotai など) を検討。

ソースコード例

// components/ThemeProvider.client.tsx
'use client'

import { createContext, useContext, useState } from 'react'

// components/ThemeProvider.client.tsx

// components/ThemeProvider.client.tsx

type Theme = 'light' | 'dark'
const ThemeCtx = createContext<{
  theme: Theme
  toggle: () => void
} | null>(null)

export const ThemeProvider = ({ children }: { children: React.ReactNode }) => {
  const [theme, setTheme] = useState<Theme>('light')
  return (
    <ThemeCtx.Provider
      value={{
        theme,
        toggle: () => setTheme((t) => (t === 'light' ? 'dark' : 'light')),
      }}
    >
      {children}
    </ThemeCtx.Provider>
  )
}

export const useTheme = () => {
  const ctx = useContext(ThemeCtx)
  if (!ctx) throw new Error('useTheme must be used inside <ThemeProvider />')
  return ctx
}

4. Composition > Inheritance

解説

  • 共有 UI やレイアウトは ラッパー として包むだけで拡張。
  • 継承は React 公式でもほぼ推奨されず、JSX の合成で十分対応できます。

ソースコード例

// components/Card.tsx
import { ReactNode } from 'react'

export const Card = ({ children }: { children: ReactNode }) => (
  <div className="rounded-2xl border p-6 shadow">{children}</div>
)
// 使用例
<Card>
  <h2 className="mb-2 text-xl font-semibold">ユーザープロフィール</h2>
  <ProfileDetails user={user} />
</Card>

5. Client / Server コンポーネントを切り分ける (Next.js App Router)

解説

  • Server Component で DB や API へのフェッチを実行し、生成された HTML を返却。
  • ユーザー操作が必要な箇所のみ "use client" を指定した Client Component に分割。
  • これにより JS バンドル・TBT (Total Blocking Time) を最小化できます。

ソースコード例

// app/todos/page.tsx  (Server Component)
import TodoList from '../../components/TodoList.client'
import { db } from '../../lib/db'

export default async function TodosPage() {
  const initialTodos = await db.todo.findMany()
  return (
    <main className="mx-auto max-w-xl p-8">
      <h1 className="mb-4 text-2xl font-bold">TODO</h1>
      {/* Client Component にデータを渡す */}
      <TodoList initialTodos={initialTodos} />
    </main>
  )
}

6. Hooks のルールを守る (Rules of Hooks)

解説

  • トップレベル でのみフックを呼び出す (if/for/ネストした関数の内側は NG)。
  • React 関数コンポーネント / カスタムフック のみで使用。
  • 再利用ロジックはカスタムフック化し、UI と分離。

ソースコード例

✅ 正しい例

function Greeting({ name }: { name: string }) {
  const [count, setCount] = useState(0) // トップレベル
  return (
    <p onClick={() => setCount((c) => c + 1)}>
      こんにちは {name}! ({count})
    </p>
  )
}

❌ 間違った例 (条件分岐の中でフック呼び出し)

function Foo({ flag }: { flag: boolean }) {
  if (flag) {
    // 🚫 ここで useEffect を呼ぶと hook の呼び出し順が変わる
    useEffect(() => {
      console.log('flag が true')
    }, [])
  }
  return <div>NG Example</div>
}

まとめ

これら 6 つの指針は互いに連携しています。たとえば 単一責任 を保つことで Hooks の再利用 が進み、Client / Server コンポーネントの分離 が自然と進行します。常に「最小責任・最小バンドル」を意識して構築すると、メンテナンス性とパフォーマンスの両立が可能です。