第9回

stateとuseState:クリックで選択キャラを切り替える

useStateでキャラクター選択状態を管理する実装を通じて、stateの概念・更新関数・再レンダリングの仕組みを対話形式で解説。

·21分で読める
たける
たける 登場人物ページ、りこさんのカードをクリックしたら詳細が表示されるようにしたくて。こう書いたんですけど動かなくて。
function CharactersPage() {
  let selectedId = 'riko'  // クリックしたら変えようとしている

  return (
    <div>
      {characters.map(c => (
        <button key={c.id} onClick={() => { selectedId = c.id }}>
          {c.name}
        </button>
      ))}
      <CharacterDetail id={selectedId} />
    </div>
  )
}
りこ
りこ `selectedId` は変わってる。でも画面は変わらない。なぜかわかる?
たける
たける 変数が変わったのにReactが気づいてないから?
りこ
りこ 正確。Reactは普通の変数の変化を監視していない。画面を更新したいなら state を使う。

stateとは

state(状態) はReactが管理する変数。普通の変数と違い、stateが変わるとReactは自動でコンポーネントを再実行して、最新の値で画面を描き直す。

普通の変数 state
変わってもReactに伝わらない 変わるとReactが検知する
画面は更新されない 画面が自動で更新される

useStateの使い方

import { useState } from 'react'

function CharactersPage() {
  // ① stateを宣言する
  const [selectedId, setSelectedId] = useState('riko')
  //    ^^^^^^^^^^^  ^^^^^^^^^^^^^   ^^^^^^^^^^^^^^^^
  //    現在の値      更新関数         初期値

  const selected = characters.find(c => c.id === selectedId)!

  return (
    <div>
      {characters.map(c => (
        <button
          key={c.id}
          onClick={() => setSelectedId(c.id)}  // ② 更新関数を呼ぶ
        >
          {c.name}
        </button>
      ))}
      {selected && <CharacterDetail character={selected} />}
    </div>
  )
}
たける
たける `const [selectedId, setSelectedId]` って配列みたいな書き方ですね。
りこ
りこ 分割代入。`useState` は `[現在の値, 更新関数]` の配列を返す。名前は自由につけていい。慣例で `[xxx, setXxx]` と名付ける。
たける
たける `setSelectedId(c.id)` を呼ぶと何が起きるんですか?
りこ
りこ stateが新しい値に更新されて、Reactがコンポーネントを最初から再実行する。今度は `selectedId` が新しい値になっているので、`selected` も変わって、画面も変わる。

再レンダリングの流れ

  1. ボタンがクリックされる
  2. setSelectedId('takeru') が呼ばれる
  3. Reactが CharactersPage を再実行する
  4. const [selectedId, ...] = useState('riko') ─ 初回以降は 'takeru' が返る
  5. selected{ id: 'takeru', name: '宮本たける', ... } になる
  6. 画面が新しい selected の内容で描き直される
たける
たける 関数が再実行されるって、`useState('riko')` も毎回呼ばれませんか? 初期値に戻っちゃわないですか?
りこ
りこ いい気づき。`useState` の初期値は最初のレンダリングだけに使われる。2回目以降はReactが内部で持っている値を返す。`'riko'` はただの「スタート地点」。

stateに何を持たせるか

今回は「選択中のキャラクターのID」をstateにした。「選択中のキャラクターオブジェクト全体」にしなかった理由がある。

// ❌ オブジェクト全体をstateにする
const [selectedCharacter, setSelectedCharacter] = useState(characters[0])

// ✅ IDだけをstateにして、オブジェクトは導出する
const [selectedId, setSelectedId] = useState('riko')
const selected = characters.find(c => c.id === selectedId)

IDだけにすることで:

  • stateがシンプルになる
  • characters 配列が更新されても selected が自動で追随する
  • シリアライズ(URLパラメータや localStorage への保存)が簡単

「stateの最小化」 ─ stateには「これがなければ導出できない情報」だけを持たせる。

ユナ
ユナ 「IDだけstateにしてオブジェクトは導出する」というのはデータベース設計の正規化と同じ考え方。マスターデータから派生できる値を別カラムに持たない、という原則がそのままフロントエンドのstateに使えている。
たける
たける ユナさんの説明でDB設計と繋がった。フロントエンドの話なのにバックエンドの言葉で腑に落ちた。
ユナ
ユナ 設計の原則は層が違っても同じことが多い。フロントとバックを両方触ると、そういうパターンが見えてくる。

画像ギャラリーにも応用する

同じ仕組みで、キャラクターカード内の画像ギャラリーも実装できる。

const CharacterCard = ({ character: c }: { character: Character }) => {
  const [selectedIndex, setSelectedIndex] = useState(0)  // 選択中の画像番号

  const allImages = c.images ?? [c.image]

  return (
    <div>
      {/* 大きい画像 */}
      <img src={allImages[selectedIndex]} alt={c.name} />

      {/* サムネイル */}
      {allImages.map((src, i) => (
        <button key={i} onClick={() => setSelectedIndex(i)}>
          <img src={src} alt={`${c.name} ${i + 1}`} />
        </button>
      ))}
    </div>
  )
}
たける
たける サムネイルを押したら大きい画像が変わった! これ、jQueryで書いたら `$('#main-img').attr('src', src)` とか書かないといけないですよね。Reactは「どのインデックスか」を管理するだけでいいのか。
りこ
りこ そこがReactの宣言的UIの強みが出る場所。「今のインデックスではこのURLを表示する」と書くだけで、切り替えの実装はReactが引き受ける。
📁 第9回完了時点のファイル構成・完成コード

フォルダ構成

src/
├── components/
│   ├── Header.tsx
│   ├── Footer.tsx
│   └── CharacterCard.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
}

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

  return (
    <div className="bg-white rounded-2xl border border-slate-100 shadow-sm p-6">
      {allImages.length > 0 && (
        <div className="mb-4">
          <img
            src={allImages[selectedIndex]}
            alt={c.name}
            className="w-full h-48 object-cover rounded-xl"
          />
          {allImages.length > 1 && (
            <div className="flex gap-2 mt-2">
              {allImages.map((src, i) => (
                <button
                  key={i}
                  onClick={() => setSelectedIndex(i)}
                  className="w-12 h-12 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>
          )}
        </div>
      )}
      <h2 className="text-xl 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-3 leading-7">{c.description}</p>
    </div>
  )
}

CharactersPage.tsx

import { useState } from 'react'
import { CharacterCard } from '../components/CharacterCard'

const characters = [
  {
    id: 'riko',
    name: '大沢りこ',
    role: 'シニアエンジニア',
    age: 39,
    description: 'フルスタックエンジニア。コードで解決できることはコードで解決するタイプ。',
    images: ['/characters/riko.jpg', '/characters/riko-bust.jpg'],
    accentColor: '#0ea5e9',
  },
  {
    id: 'takeru',
    name: '宮本たける',
    role: 'インターン',
    age: 26,
    description: '文系出身の第二新卒エンジニア。Reactを学び中。',
    image: '/characters/takeru.jpg',
    accentColor: '#8b5cf6',
  },
]

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

  return (
    <div className="max-w-5xl mx-auto px-4 sm:px-8 py-12">
      <h1 className="text-3xl font-bold text-slate-900 mb-8">登場人物</h1>

      <div className="flex gap-3 mb-8">
        {characters.map(c => (
          <button
            key={c.id}
            onClick={() => setSelectedId(c.id)}
            className={`px-4 py-2 rounded-full text-sm font-semibold transition-colors ${
              c.id === selectedId
                ? 'bg-sky-600 text-white'
                : 'bg-slate-100 text-slate-700 hover:bg-slate-200'
            }`}
          >
            {c.name}
          </button>
        ))}
      </div>

      <CharacterCard character={selected} />
    </div>
  )
}

まとめ

  • state はReactが管理する変数。変わると自動で再レンダリングが起きる
  • const [value, setValue] = useState(初期値) で宣言する
  • stateの更新は必ず setValue() 経由。直接書き換えても画面は変わらない
  • stateは最小化する。「導出できる値」はstateにしない

次の第10回では、クリック以外のイベント(入力・ホバーなど)を扱う。