第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でページ間のナビゲーションを実装する。