第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.tsx

CharacterCard.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が長くなってきたので、コンポーネントに分割していく。どこで分けるかの判断基準も解説する。