第12回

コンポーネント設計:CharacterCardとRosterCardに分割する

大きくなったcharacters-page.tsxをCharacterCard・RosterCard・Statに分割する実践を通じて、コンポーネントを分ける判断基準と設計の考え方を学ぶ。

·32分で読める
たける
たける 登場人物ページのファイルが200行超えてきました。ロスターと詳細カードと、スタッツ表示と……どこで分ければいいのかわからなくて全部1ファイルに書いてしまってます。
りこ
りこ コードを見せて。どこに境界線を引けるか一緒に考えましょう。

分割前の状態

// src/pages/characters-page.tsx(分割前・概略)
export const CharactersPage = () => {
  const [selectedId, setSelectedId] = useState(characters[0].id)
  const selected = characters.find(c => c.id === selectedId)!

  return (
    <div>
      {/* ロスター部分(約30行) */}
      <div className="grid grid-cols-5 gap-3">
        {characters.map(c => (
          <button key={c.id} onClick={() => setSelectedId(c.id)}>
            {c.icon
              ? <img src={c.icon} alt={c.name} className="..." />
              : <span>{c.name[0]}</span>
            }
            <p>{c.name}</p>
            <p>{c.role}</p>
          </button>
        ))}
      </div>

      {/* キャラクター詳細部分(約100行) */}
      <div className="character-card">
        {/* 画像エリア(約40行) */}
        ...
        {/* プロフィールエリア(約60行) */}
        ...
        {/* 年齢・経歴の小さいカード(約10行 × 2) */}
        <div className="stat">
          <p>年齢</p>
          <p>{selected.age}歳</p>
        </div>
        <div className="stat">
          <p>エンジニア歴</p>
          <p>{selected.experience}</p>
        </div>
      </div>
    </div>
  )
}

分割の判断基準

たける
たける どこで分ければいいかの基準って何ですか?
りこ
りこ 3つある。「役割が1つに絞れる」「他の場所でも使いたい」「このまま読み続けるのが辛い」。

このファイルを見ると:

部分 役割 他での再利用 読みやすさ
ロスターの各カード 1人分の選択ボタン 今後の別ページでも使えそう ✂️ 切り出せる
キャラクター詳細カード 1人の詳細表示 シリーズ記事のキャラ欄でも使えそう ✂️ 切り出せる
年齢・経歴の小カード 1つの統計値の表示 詳細カードの中で繰り返し使う ✂️ 切り出せる

分割していく

1. Stat:最小の繰り返しパーツ

// src/pages/characters-page.tsx(下部に追加)
const Stat = ({ label, value }: { label: string; value: string }) => (
  <div className="bg-slate-50 rounded-xl px-4 py-3">
    <p className="text-xs text-slate-400 mb-0.5">{label}</p>
    <p className="text-base font-bold text-slate-800">{value}</p>
  </div>
)

小さくて他ページで使う予定もないので、同じファイルの下部に定義する。別ファイルにするほどでもない。

2. RosterCard:選択ボタン1枚分

const RosterCard = ({
  character: c,
  isSelected,
  onClick,
}: {
  character: Character
  isSelected: boolean
  onClick: () => void
}) => (
  <button
    onClick={onClick}
    className="flex flex-col items-center gap-2 p-3 rounded-2xl transition-all"
    style={{
      border: `2px solid ${isSelected ? c.accentColor : '#e2e8f0'}`,
      backgroundColor: isSelected ? `${c.accentColor}12` : 'transparent',
    }}
  >
    <div className="w-16 h-16 rounded-full overflow-hidden">
      {c.icon
        ? <img src={c.icon} alt={c.name} className="w-full h-full object-cover object-top" />
        : <span className="text-2xl font-bold" style={{ color: c.accentColor }}>{c.name[0]}</span>
      }
    </div>
    <p className="text-sm font-bold text-slate-800">{c.name}</p>
    <p className="text-xs text-slate-400">{c.role}</p>
  </button>
)
たける
たける `onClick: () => void` って何ですか?
りこ
りこ 「引数なし・戻り値なしの関数」という型。`RosterCard` は「クリックされたら何かする」ことだけ知っていて、何をするかは呼び出し側に任せる。これが責務の分離。
たける
たける `RosterCard` が `setSelectedId` を直接呼ばない。コンポーネントが「選択されたら呼んでもらう関数」を受け取って使うだけ。呼ぶタイミングは自分で制御しない、と。
りこ
りこ そう。それを「コールバック経由で制御を逆転させる」という。このパターンを覚えると再利用しやすいコンポーネントが書けるようになる。

3. CharacterCard:詳細カード全体

const CharacterCard = ({ character: c }: { character: Character }) => {
  const allImages = c.images ?? (c.image ? [c.image] : [])
  const [selectedIndex, setSelectedIndex] = useState(0)
  const hasImages = allImages.length > 0

  return (
    <div className="bg-white rounded-2xl border border-slate-100 shadow-sm overflow-hidden">
      <div className="flex flex-col lg:flex-row">

        {/* 画像パネル */}
        <div className="lg:w-96 flex-shrink-0 flex flex-col"
          style={{ background: `linear-gradient(160deg, #f8fafc 0%, ${c.accentColor}18 100%)` }}>
          <div className="flex items-center justify-center px-6 pt-6" style={{ height: '500px' }}>
            {hasImages
              ? <img src={allImages[selectedIndex]} alt={c.name} className="max-w-full max-h-full object-contain" />
              : <div className="w-48 h-48 rounded-full flex items-center justify-center"
                  style={{ backgroundColor: `${c.accentColor}20` }}>
                  <span className="text-7xl font-bold" style={{ color: c.accentColor }}>{c.name[0]}</span>
                </div>
            }
          </div>

          {allImages.length > 1 && (
            <div className="flex gap-2 px-4 pb-4 pt-3">
              {allImages.map((src, i) => (
                <button key={i} onClick={() => setSelectedIndex(i)}
                  className="flex-shrink-0 rounded-lg overflow-hidden"
                  style={{ border: `2px solid ${i === selectedIndex ? c.accentColor : '#e2e8f0'}` }}>
                  <img src={src} alt="" className="w-14 h-16 object-cover object-top" loading="lazy" />
                </button>
              ))}
            </div>
          )}
          {!hasImages && <p className="text-center text-xs text-slate-300 pb-4">画像準備中</p>}
        </div>

        {/* プロフィールパネル */}
        <div className="flex-1 p-8 lg:p-10">
          <h2 className="text-4xl font-bold text-slate-900 mb-1">{c.name}</h2>
          <p className="text-xs text-slate-400 tracking-widest mb-5">{c.nameRuby}</p>
          <blockquote className="border-l-4 pl-4 mb-5 italic text-slate-600 text-sm"
            style={{ borderColor: c.accentColor }}>
            {c.catchphrase}
          </blockquote>

          <div className="grid grid-cols-2 gap-3 mb-4">
            <Stat label="年齢" value={`${c.age}`} />
            <Stat label="エンジニア歴" value={c.experience} />
          </div>

          <p className="text-slate-600 text-sm leading-7 mb-5">{c.bio}</p>

          <div className="flex flex-wrap gap-2">
            {c.tags.map(t => (
              <span key={t} className="text-xs text-slate-400">{t}</span>
            ))}
          </div>
        </div>
      </div>
    </div>
  )
}

分割後のCharactersPage

コンポーネントを切り出すと、ページコンポーネント本体がシンプルになる。

export const CharactersPage = () => {
  const [selectedId, setSelectedId] = useState(characters[0].id)
  const selected = characters.find(c => c.id === selectedId)!

  return (
    <div className="mx-auto max-w-5xl px-4 sm:px-8 py-12">
      {/* ロスター */}
      <div className="grid grid-cols-3 sm:grid-cols-5 gap-3 mb-10">
        {characters.map(c => (
          <RosterCard
            key={c.id}
            character={c}
            isSelected={c.id === selectedId}
            onClick={() => setSelectedId(c.id)}
          />
        ))}
      </div>

      {/* 詳細 */}
      <CharacterCard key={selectedId} character={selected} />
    </div>
  )
}
たける
たける"> `<CharacterCard key={selectedId} ...>` に `key` がついてますね。これはリストじゃないのに?
りこ
りこ `key` が変わるとReactはそのコンポーネントを作り直す。キャラクターを切り替えたとき、`useState(0)` のサムネイル選択が前のキャラクターのまま残るのを防ぐため。
たける
たける なるほど。りこさんのページで3枚目の画像を選んだあと、たけるさんに切り替えたら1枚目に戻る、ということですね。
りこ
りこ その通り。`key` を使ったstateのリセットは実務でよく使うテクニック。
ユナ
ユナ `onClick: () => void` でコールバックを受け取る設計、バックエンドのイベントドリブンアーキテクチャと構造が同じ。「何をするかは知らない、起きたら呼ぶ」という分離は、規模が大きくなるほど効いてくる。
たける
たける ユナさんから見てもこのパターンは使えるんですか。フロントエンドだけの話かと思ってた。
ユナ
ユナ 制御の逆転(IoC)という設計原則で、フロントもバックも関係ない。依存性注入・コールバック・イベントハンドラ、全部この考え方の変形。今日たけるが覚えたのはそういう普遍的なパターン。
📁 第12回完了時点のファイル構成・完成コード(characters-page.tsx 全体)

フォルダ構成

src/
├── pages/
│   └── characters-page.tsx   ← Stat・RosterCard・CharacterCard に分割済み
└── data/
    └── characters.ts         ← キャラクターデータ(配列)

characters-page.tsx(全体)

import { useState } from 'react'

// --- 型定義 ---
type Character = {
  id: string
  name: string
  nameRuby: string
  icon?: string
  images?: string[]
  role: string
  age: number
  experience: string
  catchphrase: string
  bio: string
  tags: string[]
  accentColor: string
}

// --- データ ---
const characters: Character[] = [
  {
    id: 'riko',
    name: '大沢りこ',
    nameRuby: 'おおさわ りこ',
    icon: '/characters/icons/riko-icon.jpg',
    images: ['/characters/riko.jpg', '/characters/riko-bust.jpg'],
    role: 'フロントエンドチームリーダー',
    age: 39,
    experience: '13年',
    catchphrase: '「なぜ動くか」を説明できない人は、「なぜ壊れたか」も説明できない。',
    bio: 'フロントエンドの技術方針とアーキテクチャを一手に担う。',
    tags: ['React', 'TypeScript'],
    accentColor: '#818cf8',
  },
  // 他のキャラクターを同様に追加...
]

// --- サブコンポーネント ---

const Stat = ({ label, value }: { label: string; value: string }) => (
  <div className="bg-slate-50 rounded-xl px-4 py-3">
    <p className="text-xs text-slate-400 mb-0.5">{label}</p>
    <p className="text-base font-bold text-slate-800">{value}</p>
  </div>
)

const RosterCard = ({
  character: c,
  isSelected,
  onClick,
}: {
  character: Character
  isSelected: boolean
  onClick: () => void
}) => (
  <button
    onClick={onClick}
    className="flex flex-col items-center gap-2 p-3 rounded-2xl transition-all"
    style={{
      border: `2px solid ${isSelected ? c.accentColor : '#e2e8f0'}`,
      backgroundColor: isSelected ? `${c.accentColor}12` : 'transparent',
    }}
  >
    <div className="w-16 h-16 rounded-full overflow-hidden bg-slate-100 flex items-center justify-center">
      {c.icon
        ? <img src={c.icon} alt={c.name} className="w-full h-full object-cover object-top" />
        : <span className="text-2xl font-bold" style={{ color: c.accentColor }}>{c.name[0]}</span>
      }
    </div>
    <p className="text-sm font-bold text-slate-800">{c.name}</p>
    <p className="text-xs text-slate-400">{c.role}</p>
  </button>
)

const CharacterCard = ({ character: c }: { character: Character }) => {
  const allImages = c.images ?? []
  const [selectedIndex, setSelectedIndex] = useState(0)
  const hasImages = allImages.length > 0

  return (
    <div className="bg-white rounded-2xl border border-slate-100 shadow-sm overflow-hidden">
      <div className="flex flex-col lg:flex-row">
        {/* 画像パネル */}
        <div className="lg:w-96 flex-shrink-0 flex flex-col items-center justify-center p-8"
          style={{ background: `linear-gradient(160deg, #f8fafc 0%, ${c.accentColor}18 100%)` }}>
          {hasImages
            ? <img src={allImages[selectedIndex]} alt={c.name} className="max-h-96 object-contain" />
            : <div className="w-48 h-48 rounded-full flex items-center justify-center"
                style={{ backgroundColor: `${c.accentColor}20` }}>
                <span className="text-7xl font-bold" style={{ color: c.accentColor }}>{c.name[0]}</span>
              </div>
          }
          {allImages.length > 1 && (
            <div className="flex gap-2 pt-4">
              {allImages.map((src, i) => (
                <button key={i} onClick={() => setSelectedIndex(i)}
                  className="rounded-lg overflow-hidden"
                  style={{ border: `2px solid ${i === selectedIndex ? c.accentColor : '#e2e8f0'}` }}>
                  <img src={src} alt="" className="w-14 h-16 object-cover object-top" loading="lazy" />
                </button>
              ))}
            </div>
          )}
        </div>

        {/* プロフィールパネル */}
        <div className="flex-1 p-8">
          <h2 className="text-4xl font-bold text-slate-900 mb-1">{c.name}</h2>
          <p className="text-xs text-slate-400 tracking-widest mb-5">{c.nameRuby}</p>
          <blockquote className="border-l-4 pl-4 mb-5 italic text-slate-600 text-sm"
            style={{ borderColor: c.accentColor }}>
            {c.catchphrase}
          </blockquote>
          <div className="grid grid-cols-2 gap-3 mb-4">
            <Stat label="年齢" value={`${c.age}`} />
            <Stat label="エンジニア歴" value={c.experience} />
          </div>
          <p className="text-slate-600 text-sm leading-7 mb-5">{c.bio}</p>
          <div className="flex flex-wrap gap-2">
            {c.tags.map(t => (
              <span key={t} className="text-xs text-slate-400 bg-slate-100 px-2 py-1 rounded">{t}</span>
            ))}
          </div>
        </div>
      </div>
    </div>
  )
}

// --- ページ ---

export const CharactersPage = () => {
  const [selectedId, setSelectedId] = useState(characters[0].id)
  const selected = characters.find(c => c.id === selectedId)!

  return (
    <div className="mx-auto max-w-5xl px-4 sm:px-8 py-12">
      {/* ロスター */}
      <div className="grid grid-cols-3 sm:grid-cols-5 gap-3 mb-10">
        {characters.map(c => (
          <RosterCard
            key={c.id}
            character={c}
            isSelected={c.id === selectedId}
            onClick={() => setSelectedId(c.id)}
          />
        ))}
      </div>

      {/* 詳細 */}
      <CharacterCard key={selectedId} character={selected} />
    </div>
  )
}

まとめ

  • 分割の基準:「役割が1つに絞れる」「他でも使いたい」「読み続けるのが辛い」
  • 小さく繰り返すパーツは同じファイルの下部に置いていい(わざわざ別ファイルにしなくてよい)
  • コールバック(onClick: () => void)を受け取る設計にすると、コンポーネントの再利用性が上がる
  • key を使うとコンポーネントのstateをリセットできる

次の第13回では、React Routerでページ間のナビゲーションを実装する。