第11回
条件付きレンダリング:画像なしキャラのプレースホルダーを作る
Reactの条件付きレンダリングを&&・三項演算子・早期returnで実装。実際に画像が揃っていないキャラクターへの対応を通じて、現場に即した使い分けを学ぶ。
·16分で読める
たける
翔太さんのデータを追加したんですけど、画像がまだないので `img` タグに `src` を渡せなくて困ってます。壊れた画像が出てしまう。
りこ
「画像がある場合は画像、ない場合は代替表示」を条件で分けれればいい。
条件付きレンダリングの3つの書き方
1. && 演算子:「あるときだけ表示」
// images があるときだけサムネイルを表示
{allImages.length > 1 && (
<div className="thumbnails">
{allImages.map((src, i) => (
<button key={i} onClick={() => setSelectedIndex(i)}>
<img src={src} alt="" />
</button>
))}
</div>
)}条件 && <JSX> は条件が true のときだけ JSX を表示する。false のときは何も表示しない。
たける
`0 && <p>何か</p>` って書いたら `0` が表示されてしまったんですけど……。
りこ
`&&` の左辺が `0` だと、Reactは `false` と解釈できずに `0` をそのまま表示してしまう。`count > 0 && ...` か `!!count && ...` にする。
// ❌ 0が表示されてしまう
{count && <p>{count}件</p>}
// ✅ 明示的にbooleanにする
{count > 0 && <p>{count}件</p>}2. 三項演算子:「AかBを表示」
// 画像がある場合は画像、ない場合はプレースホルダー
{hasImages ? (
<img src={allImages[selectedIndex]} alt={c.name} />
) : (
<div className="placeholder">
<span>{c.name[0]}</span>
</div>
)}条件 ? Aの場合 : Bの場合 の形。どちらかを必ず表示したいときに使う。
3. 早期 return:コンポーネント全体の分岐
const CharacterCard = ({ character }: { character: Character | undefined }) => {
// キャラクターが見つからなければ何も表示しない
if (!character) return null
// ここから下は character が必ず存在する
return (
<div>{character.name}</div>
)
}コンポーネントの入り口で return null すると、それ以降のJSXは処理されない。「このデータがない状態は想定外」を早めに弾くのに使う。
画像プレースホルダーを実装する
3つの書き方を組み合わせて、画像の有無に対応したキャラクターカードの画像エリアを実装する。
const CharacterCard = ({ character: c }: { character: Character }) => {
const allImages = c.images ?? (c.image ? [c.image] : [])
const hasImages = allImages.length > 0
const [selectedIndex, setSelectedIndex] = useState(0)
return (
<div className="character-card">
{/* 画像エリア */}
<div className="image-area">
{hasImages ? (
// 画像あり:大きい画像を表示
<img
src={allImages[selectedIndex]}
alt={c.name}
className="main-image"
/>
) : (
// 画像なし:名前の頭文字をプレースホルダーとして表示
<div
className="placeholder"
style={{ backgroundColor: `${c.accentColor}20` }}
>
<span style={{ color: c.accentColor }}>{c.name[0]}</span>
</div>
)}
{/* サムネイル:2枚以上あるときだけ表示 */}
{allImages.length > 1 && (
<div className="thumbnails">
{allImages.map((src, i) => (
<button
key={i}
onClick={() => setSelectedIndex(i)}
style={{
border: `2px solid ${i === selectedIndex ? c.accentColor : '#e2e8f0'}`,
}}
>
<img src={src} alt={`${c.name} ${i + 1}`} />
</button>
))}
</div>
)}
{/* 画像準備中テキスト */}
{!hasImages && (
<p className="text-slate-400 text-xs text-center pb-4">画像準備中</p>
)}
</div>
{/* プロフィール情報 */}
<div className="profile">
<h2>{c.name}</h2>
<p>{c.role}</p>
</div>
</div>
)
}
たける
翔太さんに画像を追加しても `images` を定義していなければプレースホルダーが出て、追加したら自動で切り替わるってことですね。
りこ
そう。現場でもよくある状況─「データの一部が揃っていない段階から画面を作る」。条件付きレンダリングで段階的に対応できる。
どれを使うか?
| 書き方 | 使うとき |
|---|---|
条件 && <JSX> |
表示するかしないかだけ決めたいとき |
条件 ? A : B |
2つのどちらかを表示したいとき |
早期 return |
コンポーネントの入り口で弾きたいとき・ネストが深くなりそうなとき |
ネストが深くなってきたら、早期returnに変えるとすっきりすることが多い。
// ❌ ネストが深い
return (
<div>
{isLoading ? (
<Spinner />
) : error ? (
<Error message={error} />
) : (
<Content data={data} />
)}
</div>
)
// ✅ 早期returnで整理
if (isLoading) return <Spinner />
if (error) return <Error message={error} />
return <div><Content data={data} /></div>📁 第11回完了時点のファイル構成・完成コード
フォルダ構成
src/
├── components/
│ ├── Header.tsx
│ ├── Footer.tsx
│ ├── CharacterCard.tsx ← 条件付き画像表示を追加
│ └── RosterCard.tsx
├── pages/
│ └── CharactersPage.tsx
└── App.tsxCharacterCard.tsx(条件付きレンダリング完成版)
import { useState } from 'react'
type Character = {
id: string
name: string
role: string
age: number
description: string
image?: string
images?: string[]
accentColor: string
skills?: string[]
}
export const CharacterCard = ({ character: c }: { character: Character }) => {
const allImages = c.images ?? (c.image ? [c.image] : [])
const hasImages = allImages.length > 0
const [selectedIndex, setSelectedIndex] = useState(0)
return (
<div className="bg-white rounded-2xl border border-slate-100 shadow-sm p-6">
{/* 画像エリア */}
<div className="mb-6">
{hasImages ? (
<img
src={allImages[selectedIndex]}
alt={c.name}
className="w-full h-64 object-cover rounded-xl"
/>
) : (
<div
className="w-full h-64 rounded-xl flex items-center justify-center"
style={{ backgroundColor: `${c.accentColor}20` }}
>
<span className="text-6xl font-bold" style={{ color: c.accentColor }}>
{c.name[0]}
</span>
</div>
)}
{/* サムネイル:2枚以上あるときだけ表示 */}
{allImages.length > 1 && (
<div className="flex gap-2 mt-3">
{allImages.map((src, i) => (
<button
key={i}
onClick={() => setSelectedIndex(i)}
className="w-14 h-14 rounded-lg overflow-hidden border-2 transition-colors"
style={{ borderColor: i === selectedIndex ? c.accentColor : '#e2e8f0' }}
>
<img src={src} alt="" className="w-full h-full object-cover" />
</button>
))}
</div>
)}
{/* 画像準備中テキスト */}
{!hasImages && (
<p className="text-slate-400 text-xs text-center mt-2">画像準備中</p>
)}
</div>
{/* プロフィール */}
<h2 className="text-2xl font-bold text-slate-900">{c.name}</h2>
<p className="text-sm text-slate-500 mt-1">{c.role}・{c.age}歳</p>
<p className="text-slate-600 text-sm mt-4 leading-7">{c.description}</p>
{/* スキル:あるときだけ表示 */}
{c.skills && c.skills.length > 0 && (
<div className="flex flex-wrap gap-2 mt-4">
{c.skills.map(skill => (
<span
key={skill}
className="text-xs px-3 py-1 rounded-full"
style={{ backgroundColor: `${c.accentColor}15`, color: c.accentColor }}
>
{skill}
</span>
))}
</div>
)}
</div>
)
}まとめ
&&:「あるときだけ表示」。左辺が0になる可能性があるときは> 0で明示的に比較- 三項演算子:「AかBか」。シンプルな2択に使う
- 早期
return:コンポーネントの入り口での弾き、深いネストの解消
次の第12回では、characters-page.tsxが長くなってきたので、コンポーネントに分割していく。どこで分けるかの判断基準も解説する。